recursive/
runtime_goal.rs1use std::sync::Arc;
14
15use crate::error::Result;
16use crate::llm::LlmProvider;
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 Cleared,
29}
30
31#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct GoalState {
34 pub condition: String,
36 pub status: GoalStatus,
38 pub turns: u32,
40 pub max_turns: u32,
42 pub last_reason: Option<String>,
44}
45
46#[derive(Debug, Clone)]
48pub struct GoalVerdict {
49 pub achieved: bool,
51 pub reason: String,
53}
54
55pub struct GoalEvaluator {
57 provider: Arc<dyn LlmProvider>,
58}
59
60impl GoalEvaluator {
61 pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
63 Self { provider }
64 }
65
66 pub async fn evaluate(&self, condition: &str, transcript: &[Message]) -> Result<GoalVerdict> {
72 const TAIL: usize = 20;
74 let tail = if transcript.len() > TAIL {
75 &transcript[transcript.len() - TAIL..]
76 } else {
77 transcript
78 };
79
80 let transcript_text: String = tail
82 .iter()
83 .filter_map(|m| {
84 if m.content.is_empty() {
85 None
86 } else {
87 Some(format!("[{:?}]: {}", m.role, m.content))
88 }
89 })
90 .collect::<Vec<_>>()
91 .join("\n");
92
93 let system_msg = Message::system(
94 "You are a completion evaluator. Answer YES or NO on the first line, \
95 then a single short sentence explaining why.",
96 );
97 let user_msg = Message::user(format!(
98 "Condition: {condition}\n\nRecent transcript:\n{transcript_text}\n\n\
99 Is the condition met? Answer YES or NO on the first line, then a short reason."
100 ));
101
102 let messages = vec![system_msg, user_msg];
103 let completion = self.provider.complete(&messages, &[]).await?;
104 let text = completion.content.trim().to_string();
105
106 let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
107 let achieved = first_line.starts_with("YES");
108 let reason = text
109 .lines()
110 .skip(1)
111 .collect::<Vec<_>>()
112 .join(" ")
113 .trim()
114 .to_string();
115 let reason = if reason.is_empty() {
116 text.clone()
117 } else {
118 reason
119 };
120
121 Ok(GoalVerdict { achieved, reason })
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn goal_state_serializes_and_deserializes() {
131 let state = GoalState {
132 condition: "all tests pass".into(),
133 status: GoalStatus::Pursuing,
134 turns: 3,
135 max_turns: 20,
136 last_reason: Some("still failing".into()),
137 };
138 let json = serde_json::to_string(&state).unwrap();
139 let roundtrip: GoalState = serde_json::from_str(&json).unwrap();
140 assert_eq!(roundtrip.condition, "all tests pass");
141 assert_eq!(roundtrip.status, GoalStatus::Pursuing);
142 assert_eq!(roundtrip.turns, 3);
143 assert_eq!(roundtrip.max_turns, 20);
144 assert_eq!(roundtrip.last_reason.as_deref(), Some("still failing"));
145 }
146
147 #[test]
148 fn goal_status_snake_case_serialization() {
149 assert_eq!(
150 serde_json::to_string(&GoalStatus::Pursuing).unwrap(),
151 r#""pursuing""#
152 );
153 assert_eq!(
154 serde_json::to_string(&GoalStatus::Achieved).unwrap(),
155 r#""achieved""#
156 );
157 assert_eq!(
158 serde_json::to_string(&GoalStatus::Cleared).unwrap(),
159 r#""cleared""#
160 );
161 }
162
163 #[test]
164 fn goal_verdict_achieved_flag_reflects_yes_logic() {
165 let text = "YES\nAll tests are passing now.";
167 let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
168 let achieved = first_line.starts_with("YES");
169 assert!(achieved);
170
171 let text_no = "NO\nTests still failing.";
172 let first_line_no = text_no.lines().next().unwrap_or("").trim().to_uppercase();
173 let achieved_no = first_line_no.starts_with("YES");
174 assert!(!achieved_no);
175 }
176}