1use bamboo_agent_core::Session;
25use chrono::Utc;
26use serde::{Deserialize, Serialize};
27
28use crate::runtime::gold_evaluation::GoldEvaluationResult;
29
30pub const GOAL_STATE_METADATA_KEY: &str = "goal.state";
32
33const MAX_EVAL_HISTORY: usize = 50;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum GoalRuntimeStatus {
41 Active,
43 Complete,
46 Blocked,
49 NeedInput,
51 BudgetLimited,
53}
54
55impl GoalRuntimeStatus {
56 pub fn as_str(self) -> &'static str {
57 match self {
58 Self::Active => "active",
59 Self::Complete => "complete",
60 Self::Blocked => "blocked",
61 Self::NeedInput => "need_input",
62 Self::BudgetLimited => "budget_limited",
63 }
64 }
65
66 pub fn is_active(self) -> bool {
68 matches!(self, Self::Active)
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[serde(rename_all = "snake_case")]
75pub enum GoalDeclaredStatus {
76 Complete,
77 Blocked,
78}
79
80impl GoalDeclaredStatus {
81 pub fn as_str(self) -> &'static str {
82 match self {
83 Self::Complete => "complete",
84 Self::Blocked => "blocked",
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct GoalEvalRecord {
92 pub checkpoint: String,
93 pub iteration: u32,
94 pub decision: String,
95 pub confidence: String,
96 pub reasoning: String,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
98 pub missing_information: Vec<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub next_action: Option<String>,
101 pub recorded_at: String,
102}
103
104impl GoalEvalRecord {
105 pub fn from_evaluation(result: &GoldEvaluationResult) -> Self {
107 Self {
108 checkpoint: result.checkpoint.as_str().to_string(),
109 iteration: result.iteration,
110 decision: result.decision.as_str().to_string(),
111 confidence: result.confidence.as_str().to_string(),
112 reasoning: result.reasoning.clone(),
113 missing_information: result.missing_information.clone(),
114 next_action: result.next_action.clone(),
115 recorded_at: Utc::now().to_rfc3339(),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct GoalState {
123 pub objective: String,
125 pub status: GoalRuntimeStatus,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub declared_status: Option<GoalDeclaredStatus>,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub declared_at_round: Option<u32>,
131 #[serde(default)]
133 pub continuation_count: u32,
134 #[serde(default)]
136 pub eval_history: Vec<GoalEvalRecord>,
137 pub created_at: String,
138 pub updated_at: String,
139}
140
141impl GoalState {
142 fn new(objective: impl Into<String>) -> Self {
143 let now = Utc::now().to_rfc3339();
144 Self {
145 objective: objective.into(),
146 status: GoalRuntimeStatus::Active,
147 declared_status: None,
148 declared_at_round: None,
149 continuation_count: 0,
150 eval_history: Vec::new(),
151 created_at: now.clone(),
152 updated_at: now,
153 }
154 }
155
156 pub fn declare(&mut self, status: GoalDeclaredStatus, round: u32) {
158 self.declared_status = Some(status);
159 self.declared_at_round = Some(round);
160 }
161
162 pub fn clear_declaration(&mut self) {
164 self.declared_status = None;
165 self.declared_at_round = None;
166 }
167
168 pub fn push_eval(&mut self, record: GoalEvalRecord) {
170 self.eval_history.push(record);
171 if self.eval_history.len() > MAX_EVAL_HISTORY {
172 let overflow = self.eval_history.len() - MAX_EVAL_HISTORY;
173 self.eval_history.drain(0..overflow);
174 }
175 }
176}
177
178pub fn read_goal_state(session: &Session) -> Option<GoalState> {
180 let raw = session.metadata.get(GOAL_STATE_METADATA_KEY)?;
181 serde_json::from_str::<GoalState>(raw).ok()
182}
183
184pub fn write_goal_state(session: &mut Session, mut state: GoalState) {
186 state.updated_at = Utc::now().to_rfc3339();
187 match serde_json::to_string(&state) {
188 Ok(json) => {
189 session
190 .metadata
191 .insert(GOAL_STATE_METADATA_KEY.to_string(), json);
192 }
193 Err(error) => {
194 tracing::warn!(
198 "failed to serialize goal state for session {}: {error}",
199 session.id
200 );
201 }
202 }
203}
204
205pub fn ensure_goal_state(session: &Session, objective: &str) -> GoalState {
211 match read_goal_state(session) {
212 Some(mut state) => {
213 if state.objective != objective {
214 state.objective = objective.to_string();
218 state.status = GoalRuntimeStatus::Active;
219 state.continuation_count = 0;
220 state.eval_history.clear();
221 state.clear_declaration();
222 }
223 state
224 }
225 None => GoalState::new(objective),
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use bamboo_agent_core::Session;
233
234 #[test]
235 fn round_trips_through_metadata() {
236 let mut session = Session::new("s1", "model");
237 let mut state = GoalState::new("ship the feature");
238 state.declare(GoalDeclaredStatus::Complete, 4);
239 state.continuation_count = 2;
240 state.push_eval(GoalEvalRecord {
241 checkpoint: "terminal".to_string(),
242 iteration: 4,
243 decision: "continue".to_string(),
244 confidence: "high".to_string(),
245 reasoning: "still missing tests".to_string(),
246 missing_information: vec!["the e2e test".to_string()],
247 next_action: Some("write the e2e test".to_string()),
248 recorded_at: "2026-06-15T00:00:00Z".to_string(),
249 });
250
251 write_goal_state(&mut session, state);
252 let loaded = read_goal_state(&session).expect("state persists");
253
254 assert_eq!(loaded.objective, "ship the feature");
255 assert_eq!(loaded.declared_status, Some(GoalDeclaredStatus::Complete));
256 assert_eq!(loaded.declared_at_round, Some(4));
257 assert_eq!(loaded.continuation_count, 2);
258 assert_eq!(loaded.eval_history.len(), 1);
259 assert_eq!(
260 loaded.eval_history[0].next_action.as_deref(),
261 Some("write the e2e test")
262 );
263 }
264
265 #[test]
266 fn ensure_resets_when_objective_changes() {
267 let mut session = Session::new("s1", "model");
268 let mut state = GoalState::new("old objective");
269 state.declare(GoalDeclaredStatus::Complete, 1);
270 state.status = GoalRuntimeStatus::Complete;
271 state.continuation_count = 2;
272 state.push_eval(GoalEvalRecord {
273 checkpoint: "terminal".to_string(),
274 iteration: 1,
275 decision: "achieved".to_string(),
276 confidence: "high".to_string(),
277 reasoning: "old".to_string(),
278 missing_information: Vec::new(),
279 next_action: None,
280 recorded_at: "t".to_string(),
281 });
282 write_goal_state(&mut session, state);
283
284 let refreshed = ensure_goal_state(&session, "new objective");
285 assert_eq!(refreshed.objective, "new objective");
286 assert_eq!(refreshed.status, GoalRuntimeStatus::Active);
287 assert_eq!(refreshed.declared_status, None);
288 assert_eq!(refreshed.continuation_count, 0);
290 assert!(refreshed.eval_history.is_empty());
291 }
292
293 #[test]
294 fn push_eval_trims_history() {
295 let mut state = GoalState::new("obj");
296 for i in 0..(MAX_EVAL_HISTORY + 10) {
297 state.push_eval(GoalEvalRecord {
298 checkpoint: "terminal".to_string(),
299 iteration: i as u32,
300 decision: "continue".to_string(),
301 confidence: "low".to_string(),
302 reasoning: format!("round {i}"),
303 missing_information: Vec::new(),
304 next_action: None,
305 recorded_at: "t".to_string(),
306 });
307 }
308 assert_eq!(state.eval_history.len(), MAX_EVAL_HISTORY);
309 assert_eq!(
311 state.eval_history.last().unwrap().reasoning,
312 format!("round {}", MAX_EVAL_HISTORY + 9)
313 );
314 }
315}