1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Goal-loop primitives: [`GoalStatus`], [`GoalState`], [`GoalVerdict`], and
//! the [`GoalEvaluator`] judge model.
//!
//! These types live here so [`crate::runtime`] only carries the
//! [`AgentRuntime`](crate::runtime::AgentRuntime) impl + its loop method.
//! The actual `run_goal_loop` body stays in `runtime.rs` because it mutates
//! private runtime state.
//!
//! All types are re-exported via `crate::runtime::Goal*` for backwards
//! compatibility, so external callers (e.g. `src/http.rs:496`,
//! `src/tui/app.rs`) continue to work unchanged.
use std::sync::Arc;
use crate::error::Result;
use crate::llm::LlmProvider;
use crate::message::Message;
/// Status of an active goal loop.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalStatus {
/// The loop is running — condition not yet confirmed met.
Pursuing,
/// Condition confirmed met — goal cleared after success.
Achieved,
/// Explicitly cleared by the user or the turn budget was exceeded.
Cleared,
}
/// Per-session goal state set by `/goal <condition>`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct GoalState {
/// The completion condition as written by the user.
pub condition: String,
/// Current status of the loop.
pub status: GoalStatus,
/// Turns elapsed since the goal was set.
pub turns: u32,
/// Hard cap: stop regardless of condition after this many turns.
pub max_turns: u32,
/// Most recent judge model verdict (reason string).
pub last_reason: Option<String>,
}
/// Verdict returned by [`GoalEvaluator::evaluate`].
#[derive(Debug, Clone)]
pub struct GoalVerdict {
/// Whether the condition is satisfied.
pub achieved: bool,
/// Judge's brief explanation.
pub reason: String,
}
/// Calls the LLM provider to decide whether a goal condition is met.
pub struct GoalEvaluator {
provider: Arc<dyn LlmProvider>,
}
impl GoalEvaluator {
/// Create an evaluator backed by `provider`.
pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
Self { provider }
}
/// Evaluate `condition` against the last N messages of `transcript`.
///
/// Calls the provider with a minimal YES/NO prompt (max_tokens ≈ 256 via
/// a short system instruction). The first word of the response determines
/// the verdict; any remaining text is kept as `reason`.
pub async fn evaluate(&self, condition: &str, transcript: &[Message]) -> Result<GoalVerdict> {
// Only send the last 20 messages to keep the prompt cheap.
const TAIL: usize = 20;
let tail = if transcript.len() > TAIL {
&transcript[transcript.len() - TAIL..]
} else {
transcript
};
// Format the recent transcript as plain text.
let transcript_text: String = tail
.iter()
.filter_map(|m| {
if m.content.is_empty() {
None
} else {
Some(format!("[{:?}]: {}", m.role, m.content))
}
})
.collect::<Vec<_>>()
.join("\n");
let system_msg = Message::system(
"You are a completion evaluator. Answer YES or NO on the first line, \
then a single short sentence explaining why.",
);
let user_msg = Message::user(format!(
"Condition: {condition}\n\nRecent transcript:\n{transcript_text}\n\n\
Is the condition met? Answer YES or NO on the first line, then a short reason."
));
let messages = vec![system_msg, user_msg];
let completion = self.provider.complete(&messages, &[]).await?;
let text = completion.content.trim().to_string();
let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
let achieved = first_line.starts_with("YES");
let reason = text
.lines()
.skip(1)
.collect::<Vec<_>>()
.join(" ")
.trim()
.to_string();
let reason = if reason.is_empty() {
text.clone()
} else {
reason
};
Ok(GoalVerdict { achieved, reason })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn goal_state_serializes_and_deserializes() {
let state = GoalState {
condition: "all tests pass".into(),
status: GoalStatus::Pursuing,
turns: 3,
max_turns: 20,
last_reason: Some("still failing".into()),
};
let json = serde_json::to_string(&state).unwrap();
let roundtrip: GoalState = serde_json::from_str(&json).unwrap();
assert_eq!(roundtrip.condition, "all tests pass");
assert_eq!(roundtrip.status, GoalStatus::Pursuing);
assert_eq!(roundtrip.turns, 3);
assert_eq!(roundtrip.max_turns, 20);
assert_eq!(roundtrip.last_reason.as_deref(), Some("still failing"));
}
#[test]
fn goal_status_snake_case_serialization() {
assert_eq!(
serde_json::to_string(&GoalStatus::Pursuing).unwrap(),
r#""pursuing""#
);
assert_eq!(
serde_json::to_string(&GoalStatus::Achieved).unwrap(),
r#""achieved""#
);
assert_eq!(
serde_json::to_string(&GoalStatus::Cleared).unwrap(),
r#""cleared""#
);
}
#[test]
fn goal_verdict_achieved_flag_reflects_yes_logic() {
// Directly test the verdict construction used in evaluate().
let text = "YES\nAll tests are passing now.";
let first_line = text.lines().next().unwrap_or("").trim().to_uppercase();
let achieved = first_line.starts_with("YES");
assert!(achieved);
let text_no = "NO\nTests still failing.";
let first_line_no = text_no.lines().next().unwrap_or("").trim().to_uppercase();
let achieved_no = first_line_no.starts_with("YES");
assert!(!achieved_no);
}
}