use bamboo_agent_core::Session;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use crate::runtime::gold_evaluation::GoldEvaluationResult;
pub const GOAL_STATE_METADATA_KEY: &str = "goal.state";
const MAX_EVAL_HISTORY: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalRuntimeStatus {
Active,
Complete,
Blocked,
NeedInput,
BudgetLimited,
}
impl GoalRuntimeStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Complete => "complete",
Self::Blocked => "blocked",
Self::NeedInput => "need_input",
Self::BudgetLimited => "budget_limited",
}
}
pub fn is_active(self) -> bool {
matches!(self, Self::Active)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum GoalDeclaredStatus {
Complete,
Blocked,
}
impl GoalDeclaredStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Complete => "complete",
Self::Blocked => "blocked",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalEvalRecord {
pub checkpoint: String,
pub iteration: u32,
pub decision: String,
pub confidence: String,
pub reasoning: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub missing_information: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_action: Option<String>,
pub recorded_at: String,
}
impl GoalEvalRecord {
pub fn from_evaluation(result: &GoldEvaluationResult) -> Self {
Self {
checkpoint: result.checkpoint.as_str().to_string(),
iteration: result.iteration,
decision: result.decision.as_str().to_string(),
confidence: result.confidence.as_str().to_string(),
reasoning: result.reasoning.clone(),
missing_information: result.missing_information.clone(),
next_action: result.next_action.clone(),
recorded_at: Utc::now().to_rfc3339(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalState {
pub objective: String,
pub status: GoalRuntimeStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_status: Option<GoalDeclaredStatus>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub declared_at_round: Option<u32>,
#[serde(default)]
pub continuation_count: u32,
#[serde(default)]
pub eval_history: Vec<GoalEvalRecord>,
pub created_at: String,
pub updated_at: String,
}
impl GoalState {
fn new(objective: impl Into<String>) -> Self {
let now = Utc::now().to_rfc3339();
Self {
objective: objective.into(),
status: GoalRuntimeStatus::Active,
declared_status: None,
declared_at_round: None,
continuation_count: 0,
eval_history: Vec::new(),
created_at: now.clone(),
updated_at: now,
}
}
pub fn declare(&mut self, status: GoalDeclaredStatus, round: u32) {
self.declared_status = Some(status);
self.declared_at_round = Some(round);
}
pub fn clear_declaration(&mut self) {
self.declared_status = None;
self.declared_at_round = None;
}
pub fn push_eval(&mut self, record: GoalEvalRecord) {
self.eval_history.push(record);
if self.eval_history.len() > MAX_EVAL_HISTORY {
let overflow = self.eval_history.len() - MAX_EVAL_HISTORY;
self.eval_history.drain(0..overflow);
}
}
}
pub fn read_goal_state(session: &Session) -> Option<GoalState> {
let raw = session.metadata.get(GOAL_STATE_METADATA_KEY)?;
serde_json::from_str::<GoalState>(raw).ok()
}
pub fn write_goal_state(session: &mut Session, mut state: GoalState) {
state.updated_at = Utc::now().to_rfc3339();
match serde_json::to_string(&state) {
Ok(json) => {
session
.metadata
.insert(GOAL_STATE_METADATA_KEY.to_string(), json);
}
Err(error) => {
tracing::warn!(
"failed to serialize goal state for session {}: {error}",
session.id
);
}
}
}
pub fn ensure_goal_state(session: &Session, objective: &str) -> GoalState {
match read_goal_state(session) {
Some(mut state) => {
if state.objective != objective {
state.objective = objective.to_string();
state.status = GoalRuntimeStatus::Active;
state.continuation_count = 0;
state.eval_history.clear();
state.clear_declaration();
}
state
}
None => GoalState::new(objective),
}
}
#[cfg(test)]
mod tests {
use super::*;
use bamboo_agent_core::Session;
#[test]
fn round_trips_through_metadata() {
let mut session = Session::new("s1", "model");
let mut state = GoalState::new("ship the feature");
state.declare(GoalDeclaredStatus::Complete, 4);
state.continuation_count = 2;
state.push_eval(GoalEvalRecord {
checkpoint: "terminal".to_string(),
iteration: 4,
decision: "continue".to_string(),
confidence: "high".to_string(),
reasoning: "still missing tests".to_string(),
missing_information: vec!["the e2e test".to_string()],
next_action: Some("write the e2e test".to_string()),
recorded_at: "2026-06-15T00:00:00Z".to_string(),
});
write_goal_state(&mut session, state);
let loaded = read_goal_state(&session).expect("state persists");
assert_eq!(loaded.objective, "ship the feature");
assert_eq!(loaded.declared_status, Some(GoalDeclaredStatus::Complete));
assert_eq!(loaded.declared_at_round, Some(4));
assert_eq!(loaded.continuation_count, 2);
assert_eq!(loaded.eval_history.len(), 1);
assert_eq!(
loaded.eval_history[0].next_action.as_deref(),
Some("write the e2e test")
);
}
#[test]
fn ensure_resets_when_objective_changes() {
let mut session = Session::new("s1", "model");
let mut state = GoalState::new("old objective");
state.declare(GoalDeclaredStatus::Complete, 1);
state.status = GoalRuntimeStatus::Complete;
state.continuation_count = 2;
state.push_eval(GoalEvalRecord {
checkpoint: "terminal".to_string(),
iteration: 1,
decision: "achieved".to_string(),
confidence: "high".to_string(),
reasoning: "old".to_string(),
missing_information: Vec::new(),
next_action: None,
recorded_at: "t".to_string(),
});
write_goal_state(&mut session, state);
let refreshed = ensure_goal_state(&session, "new objective");
assert_eq!(refreshed.objective, "new objective");
assert_eq!(refreshed.status, GoalRuntimeStatus::Active);
assert_eq!(refreshed.declared_status, None);
assert_eq!(refreshed.continuation_count, 0);
assert!(refreshed.eval_history.is_empty());
}
#[test]
fn push_eval_trims_history() {
let mut state = GoalState::new("obj");
for i in 0..(MAX_EVAL_HISTORY + 10) {
state.push_eval(GoalEvalRecord {
checkpoint: "terminal".to_string(),
iteration: i as u32,
decision: "continue".to_string(),
confidence: "low".to_string(),
reasoning: format!("round {i}"),
missing_information: Vec::new(),
next_action: None,
recorded_at: "t".to_string(),
});
}
assert_eq!(state.eval_history.len(), MAX_EVAL_HISTORY);
assert_eq!(
state.eval_history.last().unwrap().reasoning,
format!("round {}", MAX_EVAL_HISTORY + 9)
);
}
}