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::LlmProvider;
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    /// Explicitly cleared by the user or the turn budget was exceeded.
28    Cleared,
29}
30
31/// Per-session goal state set by `/goal <condition>`.
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct GoalState {
34    /// The completion condition as written by the user.
35    pub condition: String,
36    /// Current status of the loop.
37    pub status: GoalStatus,
38    /// Turns elapsed since the goal was set.
39    pub turns: u32,
40    /// Hard cap: stop regardless of condition after this many turns.
41    pub max_turns: u32,
42    /// Most recent judge model verdict (reason string).
43    pub last_reason: Option<String>,
44}
45
46/// Verdict returned by [`GoalEvaluator::evaluate`].
47#[derive(Debug, Clone)]
48pub struct GoalVerdict {
49    /// Whether the condition is satisfied.
50    pub achieved: bool,
51    /// Judge's brief explanation.
52    pub reason: String,
53}
54
55/// Calls the LLM provider to decide whether a goal condition is met.
56pub struct GoalEvaluator {
57    provider: Arc<dyn LlmProvider>,
58}
59
60impl GoalEvaluator {
61    /// Create an evaluator backed by `provider`.
62    pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
63        Self { provider }
64    }
65
66    /// Evaluate `condition` against the last N messages of `transcript`.
67    ///
68    /// Calls the provider with a minimal YES/NO prompt (max_tokens ≈ 256 via
69    /// a short system instruction).  The first word of the response determines
70    /// the verdict; any remaining text is kept as `reason`.
71    pub async fn evaluate(&self, condition: &str, transcript: &[Message]) -> Result<GoalVerdict> {
72        // Only send the last 20 messages to keep the prompt cheap.
73        const TAIL: usize = 20;
74        let tail = if transcript.len() > TAIL {
75            &transcript[transcript.len() - TAIL..]
76        } else {
77            transcript
78        };
79
80        // Format the recent transcript as plain text.
81        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        // Directly test the verdict construction used in evaluate().
166        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}