1use std::sync::Arc;
14
15use crate::error::Result;
16use crate::llm::ChatProvider;
17use crate::message::Message;
18
19#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum GoalStatus {
23 Pursuing,
25 Achieved,
27}
28
29#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct GoalState {
32 pub condition: String,
34 pub status: GoalStatus,
36 pub turns: u32,
38 pub max_turns: u32,
40 pub last_reason: Option<String>,
42}
43
44#[derive(Debug, Clone)]
46pub struct GoalVerdict {
47 pub achieved: bool,
49 pub reason: String,
51}
52
53pub struct GoalEvaluator {
55 provider: Arc<dyn ChatProvider>,
56}
57
58impl GoalEvaluator {
59 pub fn new(provider: Arc<dyn ChatProvider>) -> Self {
61 Self { provider }
62 }
63
64 pub async fn evaluate(&self, condition: &str, transcript: &[Message]) -> Result<GoalVerdict> {
75 let transcript_text: String = transcript
77 .iter()
78 .filter_map(|m| {
79 if m.content.is_empty() {
80 None
81 } else {
82 Some(format!("[{:?}]: {}", m.role, m.content))
83 }
84 })
85 .collect::<Vec<_>>()
86 .join("\n");
87
88 let system_msg = Message::system(
89 "You are a completion evaluator. Answer YES or NO on the first line, \
90 then a single short sentence explaining why.",
91 );
92 let user_msg = Message::user(format!(
93 "Condition: {condition}\n\nRecent transcript:\n{transcript_text}\n\n\
94 Is the condition met? Answer YES or NO on the first line, then a short reason."
95 ));
96
97 let messages = vec![system_msg, user_msg];
98 let completion = self.provider.complete(&messages, &[]).await?;
99 let text = completion.content.trim().to_string();
100
101 let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
102 let achieved = first_line.starts_with("YES");
103 let reason = text
104 .lines()
105 .skip(1)
106 .collect::<Vec<_>>()
107 .join(" ")
108 .trim()
109 .to_string();
110 let reason = if reason.is_empty() {
111 text.clone()
112 } else {
113 reason
114 };
115
116 Ok(GoalVerdict { achieved, reason })
117 }
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use std::sync::Mutex;
124
125 use crate::error::Result;
126 use crate::llm::{ChatProvider, Completion};
127 use crate::message::Role;
128 use async_trait::async_trait;
129
130 #[test]
131 fn goal_state_serializes_and_deserializes() {
132 let state = GoalState {
133 condition: "all tests pass".into(),
134 status: GoalStatus::Pursuing,
135 turns: 3,
136 max_turns: 20,
137 last_reason: Some("still failing".into()),
138 };
139 let json = serde_json::to_string(&state).unwrap();
140 let roundtrip: GoalState = serde_json::from_str(&json).unwrap();
141 assert_eq!(roundtrip.condition, "all tests pass");
142 assert_eq!(roundtrip.status, GoalStatus::Pursuing);
143 assert_eq!(roundtrip.turns, 3);
144 assert_eq!(roundtrip.max_turns, 20);
145 assert_eq!(roundtrip.last_reason.as_deref(), Some("still failing"));
146 }
147
148 #[test]
149 fn goal_status_snake_case_serialization() {
150 assert_eq!(
151 serde_json::to_string(&GoalStatus::Pursuing).unwrap(),
152 r#""pursuing""#
153 );
154 assert_eq!(
155 serde_json::to_string(&GoalStatus::Achieved).unwrap(),
156 r#""achieved""#
157 );
158 }
159
160 #[test]
161 fn goal_verdict_achieved_flag_reflects_yes_logic() {
162 let text = "YES\nAll tests are passing now.";
164 let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
165 let achieved = first_line.starts_with("YES");
166 assert!(achieved);
167
168 let text_no = "NO\nTests still failing.";
169 let first_line_no = text_no.lines().next().unwrap_or("").trim().to_uppercase();
170 let achieved_no = first_line_no.starts_with("YES");
171 assert!(!achieved_no);
172 }
173
174 struct CapturingProvider {
176 captured_user_content: Mutex<String>,
177 }
178
179 #[async_trait]
180 impl ChatProvider for CapturingProvider {
181 async fn complete(
182 &self,
183 messages: &[Message],
184 _tools: &[crate::llm::ToolSpec],
185 ) -> Result<Completion> {
186 if let Some(last) = messages.last() {
188 *self.captured_user_content.lock().unwrap() = last.content.clone();
189 }
190 Ok(Completion {
191 content: "YES\nAll conditions met.".into(),
192 tool_calls: vec![],
193 finish_reason: Some("stop".into()),
194 usage: None,
195 reasoning_content: None,
196 })
197 }
198 }
199
200 struct NoProvider;
203 #[async_trait]
204 impl ChatProvider for NoProvider {
205 async fn complete(
206 &self,
207 _messages: &[Message],
208 _tools: &[crate::llm::ToolSpec],
209 ) -> Result<Completion> {
210 Ok(Completion {
211 content: "NO\nCondition not yet met.".into(),
212 tool_calls: vec![],
213 finish_reason: Some("stop".into()),
214 usage: None,
215 reasoning_content: None,
216 })
217 }
218 }
219
220 #[tokio::test]
221 async fn evaluate_returns_not_achieved_for_no_response() {
222 let evaluator = GoalEvaluator::new(Arc::new(NoProvider));
224 let verdict = evaluator
225 .evaluate("all tests pass", &[Message::user("still failing")])
226 .await
227 .unwrap();
228 assert!(!verdict.achieved, "NO response must produce achieved=false");
229 assert!(
230 !verdict.reason.is_empty(),
231 "reason must be non-empty even for NO"
232 );
233 }
234
235 #[tokio::test]
236 async fn evaluate_skips_empty_content_messages() {
237 let provider = Arc::new(CapturingProvider {
239 captured_user_content: Mutex::new(String::new()),
240 });
241 let evaluator = GoalEvaluator::new(provider.clone());
242 let transcript = vec![
243 Message::user("visible"),
244 Message::user(""), ];
246 let _ = evaluator.evaluate("cond", &transcript).await.unwrap();
247 let captured = provider.captured_user_content.lock().unwrap().clone();
248 assert!(
249 captured.contains("visible"),
250 "non-empty message must appear in prompt"
251 );
252 let occurrences = captured.matches("[User]:").count();
255 assert_eq!(
256 occurrences, 1,
257 "only 1 non-empty User message should be included; got {occurrences} occurrences in: {captured}"
258 );
259 }
260
261 struct OneLinerProvider;
264 #[async_trait]
265 impl ChatProvider for OneLinerProvider {
266 async fn complete(
267 &self,
268 _messages: &[Message],
269 _tools: &[crate::llm::ToolSpec],
270 ) -> Result<Completion> {
271 Ok(Completion {
272 content: "YES".into(), tool_calls: vec![],
274 finish_reason: Some("stop".into()),
275 usage: None,
276 reasoning_content: None,
277 })
278 }
279 }
280
281 #[tokio::test]
282 async fn evaluate_reason_falls_back_to_full_text_when_single_line() {
283 let evaluator = GoalEvaluator::new(Arc::new(OneLinerProvider));
285 let verdict = evaluator
286 .evaluate("cond", &[Message::user("ok")])
287 .await
288 .unwrap();
289 assert!(verdict.achieved, "single-line YES must be achieved=true");
290 assert!(
292 !verdict.reason.is_empty(),
293 "reason must fall back to the full response"
294 );
295 assert_eq!(
296 verdict.reason, "YES",
297 "reason should equal the full response when there is no second line"
298 );
299 }
300
301 #[tokio::test]
305 async fn evaluate_preserves_all_messages_beyond_old_tail_20() {
306 let transcript: Vec<Message> = (0..25)
308 .map(|i| Message {
309 role: Role::User,
310 content: format!("message-{i:02}"),
311 tool_calls: vec![],
312 tool_call_id: None,
313 reasoning_content: None,
314 is_compaction_summary: false,
315 })
316 .collect();
317
318 let provider = Arc::new(CapturingProvider {
319 captured_user_content: Mutex::new(String::new()),
320 });
321 let evaluator = GoalEvaluator::new(provider.clone());
322
323 let verdict = evaluator
324 .evaluate("test condition", &transcript)
325 .await
326 .unwrap();
327 assert!(verdict.achieved);
328
329 let captured = provider.captured_user_content.lock().unwrap().clone();
330 for i in 0..25 {
332 let needle = format!("message-{i:02}");
333 assert!(
334 captured.contains(&needle),
335 "transcript message {i} ({needle}) missing from prompt;\n\
336 prompt was: {captured}"
337 );
338 }
339 }
340}