Skip to main content

recursive/
runtime_goal.rs

1//! Goal-loop primitives: [`GoalStatus`], [`GoalState`], [`GoalVerdict`], and
2//! the [`GoalEvaluator`] judge model.
3//!
4//! These types live here so [`crate::runtime`] only carries the
5//! [`AgentRuntime`](crate::runtime::AgentRuntime) impl + its loop method.
6//! The actual `run_goal_loop` body stays in `runtime.rs` because it mutates
7//! private runtime state.
8//!
9//! All types are re-exported via `crate::runtime::Goal*` for backwards
10//! compatibility, so external callers (e.g. `src/http.rs:496`,
11//! `src/tui/app.rs`) continue to work unchanged.
12
13use std::sync::Arc;
14
15use crate::error::Result;
16use crate::llm::ChatProvider;
17use crate::message::Message;
18
19/// Status of an active goal loop.
20#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
21#[serde(rename_all = "snake_case")]
22pub enum GoalStatus {
23    /// The loop is running — condition not yet confirmed met.
24    Pursuing,
25    /// Condition confirmed met — goal cleared after success.
26    Achieved,
27}
28
29/// Per-session goal state set by `/goal <condition>`.
30#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
31pub struct GoalState {
32    /// The completion condition as written by the user.
33    pub condition: String,
34    /// Current status of the loop.
35    pub status: GoalStatus,
36    /// Turns elapsed since the goal was set.
37    pub turns: u32,
38    /// Hard cap: stop regardless of condition after this many turns.
39    pub max_turns: u32,
40    /// Most recent judge model verdict (reason string).
41    pub last_reason: Option<String>,
42}
43
44/// Verdict returned by [`GoalEvaluator::evaluate`].
45#[derive(Debug, Clone)]
46pub struct GoalVerdict {
47    /// Whether the condition is satisfied.
48    pub achieved: bool,
49    /// Judge's brief explanation.
50    pub reason: String,
51}
52
53/// Calls the LLM provider to decide whether a goal condition is met.
54pub struct GoalEvaluator {
55    provider: Arc<dyn ChatProvider>,
56}
57
58impl GoalEvaluator {
59    /// Create an evaluator backed by `provider`.
60    pub fn new(provider: Arc<dyn ChatProvider>) -> Self {
61        Self { provider }
62    }
63
64    /// Evaluate `condition` against the given `transcript`.
65    ///
66    /// Callers are responsible for pre-slicing `transcript` to the desired
67    /// number of recent messages (e.g. via
68    /// [`AgentRuntime::transcript_tail`]).  This method uses the full slice
69    /// as-is — it does NOT perform any further truncation.
70    ///
71    /// Calls the provider with a minimal YES/NO prompt (max_tokens ≈ 256 via
72    /// a short system instruction).  The first word of the response determines
73    /// the verdict; any remaining text is kept as `reason`.
74    pub async fn evaluate(&self, condition: &str, transcript: &[Message]) -> Result<GoalVerdict> {
75        // Format the recent transcript as plain text.
76        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        // Directly test the verdict construction used in evaluate().
163        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    /// Mock provider that captures the user prompt for inspection.
175    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            // Capture the last (user) message content.
187            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    /// Provider that returns a fixed "NO" response, used to confirm
201    /// `evaluate` can return `achieved = false`.
202    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        // kills `starts_with("YES")` → `starts_with("NO")` mutation
223        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        // kills `if m.content.is_empty() { None }` guard removal mutation
238        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(""), // empty — must be skipped
245        ];
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        // Count occurrences of [User]: in the prompt; only the "visible"
253        // message should appear, not the empty one.
254        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    /// Provider returning a bare one-liner (no second line) so `reason` falls
262    /// back to the full response text.
263    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(), // single line — no reason line
273                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        // kills the `if reason.is_empty() { text.clone() }` → `""` mutation
284        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        // reason must not be empty — it falls back to the full "YES" text
291        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    /// Goal-301: a transcript with 25 messages must NOT be further
302    /// truncated inside `evaluate()`.  All 25 messages must contribute
303    /// to the user prompt sent to the provider.
304    #[tokio::test]
305    async fn evaluate_preserves_all_messages_beyond_old_tail_20() {
306        // Build 25 distinct user messages.
307        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        // Each message should appear with its formatted content.
331        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}