Skip to main content

navi_core/goal/
runtime.rs

1use crate::goal::types::SessionGoal;
2use std::sync::RwLock;
3
4use super::accounting::GoalAccountingState;
5use super::steering;
6
7/// Per-session goal lifecycle manager.
8///
9/// Drives auto-continuation, status transitions, and steering prompt injection.
10/// All mutations go through `with_goal_state` which serializes access via a
11/// dedicated state lock, preventing external `set`/`clear` calls from
12/// interfering with `continue_if_idle`.
13pub struct GoalRuntimeHandle {
14    /// The currently bound session id.
15    session_id: RwLock<Option<String>>,
16    /// The goal state protected by a mutex.
17    goal: RwLock<Option<SessionGoal>>,
18    /// Accounting state for the current turn.
19    accounting: RwLock<Option<GoalAccountingState>>,
20    /// Whether auto-continuation is enabled.
21    auto_continue: RwLock<bool>,
22    /// Serializes goal state mutations to prevent race conditions between
23    /// external set/clear and internal continue_if_idle.
24    state_lock: std::sync::Mutex<()>,
25}
26
27impl GoalRuntimeHandle {
28    /// Creates a new runtime handle with an optional initial goal.
29    pub fn new(initial_goal: Option<SessionGoal>) -> Self {
30        // Initialize accounting if there's an active goal.
31        let accounting = initial_goal
32            .as_ref()
33            .map(|g| GoalAccountingState::new(g.clone()));
34        let session_id = initial_goal
35            .as_ref()
36            .and_then(|goal| (!goal.session_id.is_empty()).then(|| goal.session_id.clone()));
37        Self {
38            session_id: RwLock::new(session_id),
39            goal: RwLock::new(initial_goal),
40            accounting: RwLock::new(accounting),
41            auto_continue: RwLock::new(true),
42            state_lock: std::sync::Mutex::new(()),
43        }
44    }
45
46    // ── Goal accessors ──────────────────────────────────────────
47
48    /// Returns the current goal, if any.
49    pub fn get_goal(&self) -> Option<SessionGoal> {
50        self.goal.read().unwrap_or_else(|e| e.into_inner()).clone()
51    }
52
53    /// Binds this runtime to a session id and rewrites any loaded goal to match.
54    pub fn set_session_id(&self, session_id: impl Into<String>) {
55        let session_id = session_id.into();
56        *self.session_id.write().unwrap_or_else(|e| e.into_inner()) = Some(session_id.clone());
57        if let Some(ref mut goal) = *self.goal.write().unwrap_or_else(|e| e.into_inner()) {
58            goal.session_id = session_id;
59            goal.updated_at = crate::session::current_unix_timestamp();
60        }
61    }
62
63    /// Sets or replaces the goal.
64    pub fn set_objective(&self, objective: String, token_budget: Option<i64>) -> SessionGoal {
65        self.set_objective_with_short_description(objective, None, token_budget)
66    }
67
68    /// Sets or replaces the goal with an optional compact UI label.
69    ///
70    /// When a goal already exists, mutates it in place (same `goal_id`) and
71    /// re-activates it — used by host/SDK `set_goal`. Model `create_goal` must
72    /// call [`create_new_goal`] so terminal goals get a fresh id.
73    pub fn set_objective_with_short_description(
74        &self,
75        objective: String,
76        short_description: Option<String>,
77        token_budget: Option<i64>,
78    ) -> SessionGoal {
79        let mut goal_guard = self.goal.write().unwrap_or_else(|e| e.into_inner());
80        if let Some(ref mut goal) = *goal_guard {
81            goal.objective = objective;
82            goal.short_description = short_description;
83            goal.token_budget = token_budget;
84            goal.status = crate::goal::types::GoalStatus::Active;
85            goal.consecutive_blocked_turns = 0;
86            goal.block_reason = None;
87            goal.updated_at = crate::session::current_unix_timestamp();
88            let new_goal = goal.clone();
89            drop(goal_guard);
90            *self.accounting.write().unwrap_or_else(|e| e.into_inner()) =
91                Some(GoalAccountingState::new(new_goal.clone()));
92            new_goal
93        } else {
94            let session_id = self
95                .session_id
96                .read()
97                .unwrap_or_else(|e| e.into_inner())
98                .clone()
99                .unwrap_or_default();
100            let mut new_goal = SessionGoal::new(session_id, objective, token_budget);
101            new_goal.short_description = short_description;
102            let cloned = new_goal.clone();
103            *goal_guard = Some(new_goal);
104            drop(goal_guard);
105            *self.accounting.write().unwrap_or_else(|e| e.into_inner()) =
106                Some(GoalAccountingState::new(cloned.clone()));
107            cloned
108        }
109    }
110
111    /// Creates a brand-new active goal (new `goal_id`), replacing any prior goal.
112    ///
113    /// Caller must ensure there is no unfinished goal when this is used from the
114    /// model-facing `create_goal` tool.
115    pub fn create_new_goal(
116        &self,
117        objective: String,
118        short_description: Option<String>,
119        token_budget: Option<i64>,
120    ) -> SessionGoal {
121        let session_id = self
122            .session_id
123            .read()
124            .unwrap_or_else(|e| e.into_inner())
125            .clone()
126            .unwrap_or_default();
127        let mut new_goal = SessionGoal::new(session_id, objective, token_budget);
128        new_goal.short_description = short_description;
129        let cloned = new_goal.clone();
130        *self.goal.write().unwrap_or_else(|e| e.into_inner()) = Some(new_goal);
131        *self.accounting.write().unwrap_or_else(|e| e.into_inner()) =
132            Some(GoalAccountingState::new(cloned.clone()));
133        cloned
134    }
135
136    /// Updates the stored goal (used after status transitions).
137    pub fn update_goal(&self, mut goal: SessionGoal) {
138        if let Some(session_id) = self
139            .session_id
140            .read()
141            .unwrap_or_else(|e| e.into_inner())
142            .clone()
143        {
144            goal.session_id = session_id;
145        }
146        *self.goal.write().unwrap_or_else(|e| e.into_inner()) = Some(goal.clone());
147        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
148            acct.replace_goal(goal);
149        }
150    }
151
152    /// Clears the current goal.
153    pub fn clear_goal(&self) {
154        *self.goal.write().unwrap_or_else(|e| e.into_inner()) = None;
155        *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = None;
156        *self.session_id.write().unwrap_or_else(|e| e.into_inner()) = None;
157    }
158
159    /// Updates the checklist on the current goal. Returns the updated goal if successful.
160    pub fn update_checklist(&self, tasks: Vec<super::types::GoalTask>) -> Option<SessionGoal> {
161        let mut goal = self.goal.write().unwrap_or_else(|e| e.into_inner());
162        if let Some(ref mut g) = *goal {
163            g.set_checklist(tasks);
164            let cloned = g.clone();
165            drop(goal);
166            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
167                acct.replace_goal(cloned.clone());
168            }
169            Some(cloned)
170        } else {
171            None
172        }
173    }
174
175    /// Updates a single task's status in the goal checklist.
176    pub fn update_task_status(
177        &self,
178        task_id: usize,
179        status: super::types::TaskStatus,
180    ) -> Option<SessionGoal> {
181        let mut goal = self.goal.write().unwrap_or_else(|e| e.into_inner());
182        if let Some(ref mut g) = *goal {
183            if !g.update_task_status(task_id, status) {
184                return None;
185            }
186            let cloned = g.clone();
187            drop(goal);
188            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
189                acct.replace_goal(cloned.clone());
190            }
191            Some(cloned)
192        } else {
193            None
194        }
195    }
196
197    // ── Accounting ─────────────────────────────────────────────
198
199    /// Starts turn accounting for the active goal.
200    pub fn start_turn(&self) {
201        let goal = self.get_goal();
202        if let Some(goal) = goal {
203            if goal.status.should_auto_continue() {
204                let acct = GoalAccountingState::new(goal);
205                acct.start_turn();
206                *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = Some(acct);
207            }
208        }
209    }
210
211    /// Records token usage during a turn.
212    pub fn record_tokens(&self, delta: i64) -> bool {
213        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
214            let exceeded = acct.record_token_usage(delta);
215            if exceeded {
216                // Sync back immediately so budget_limit_prompt works.
217                if let Some(goal) = acct.snapshot() {
218                    self.update_goal(goal);
219                }
220            }
221            exceeded
222        } else {
223            false
224        }
225    }
226
227    /// Finishes turn accounting and updates the stored goal.
228    pub fn finish_turn(&self) {
229        if let Some(acct) = self
230            .accounting
231            .read()
232            .unwrap_or_else(|e| e.into_inner())
233            .as_ref()
234        {
235            if let Some(goal) = acct.finish_turn() {
236                self.update_goal(goal);
237            }
238        }
239        *self.accounting.write().unwrap_or_else(|e| e.into_inner()) = None;
240    }
241
242    // ── Status transitions ─────────────────────────────────────
243
244    /// Transitions the goal to Blocked after consecutive blocked turns.
245    pub fn record_blocked_turn(&self, reason: &str) -> bool {
246        let mut goal_guard = self.goal.write().unwrap_or_else(|e| e.into_inner());
247        if let Some(ref mut goal) = *goal_guard {
248            let became_blocked = goal.record_blocked_turn(reason);
249            let updated = goal.clone();
250            drop(goal_guard);
251            if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
252                acct.replace_goal(updated);
253            }
254            became_blocked
255        } else {
256            false
257        }
258    }
259
260    /// Transitions the goal to UsageLimited.
261    pub fn mark_usage_limited(&self) {
262        if let Some(ref acct) = *self.accounting.read().unwrap_or_else(|e| e.into_inner()) {
263            if let Some(mut goal) = acct.snapshot() {
264                goal.transition_to(crate::goal::types::GoalStatus::UsageLimited);
265                self.update_goal(goal);
266            }
267        } else if let Some(mut goal) = self.get_goal() {
268            goal.transition_to(crate::goal::types::GoalStatus::UsageLimited);
269            self.update_goal(goal);
270        }
271    }
272
273    // ── Auto-continuation ──────────────────────────────────────
274
275    /// If the goal is active and should auto-continue, returns a steering
276    /// continuation prompt to inject into the conversation.
277    /// Acquires the state lock to prevent race conditions with concurrent
278    /// set/clear operations.
279    pub fn continue_if_idle(&self) -> Option<String> {
280        let _lock = self.state_lock.lock().unwrap_or_else(|e| e.into_inner());
281        let goal = self.get_goal()?;
282        if !goal.status.should_auto_continue() {
283            return None;
284        }
285        let auto = *self.auto_continue.read().unwrap_or_else(|e| e.into_inner());
286        if !auto {
287            return None;
288        }
289        Some(steering::build_continuation_prompt(&goal))
290    }
291
292    /// Returns a budget-limit steering prompt if the budget is exceeded.
293    pub fn budget_limit_prompt(&self) -> Option<String> {
294        let goal = self.get_goal()?;
295        if goal.is_budget_exceeded() {
296            Some(steering::build_budget_limit_prompt(&goal))
297        } else {
298            None
299        }
300    }
301
302    /// Returns an objective-updated steering prompt.
303    pub fn objective_updated_prompt(&self) -> Option<String> {
304        let goal = self.get_goal()?;
305        if goal.status == crate::goal::types::GoalStatus::Active {
306            Some(steering::build_objective_updated_prompt(&goal))
307        } else {
308            None
309        }
310    }
311
312    /// Enables or disables auto-continuation.
313    pub fn set_auto_continue(&self, enabled: bool) {
314        *self
315            .auto_continue
316            .write()
317            .unwrap_or_else(|e| e.into_inner()) = enabled;
318    }
319
320    /// Returns `true` if the goal is in a terminal state.
321    pub fn is_terminal(&self) -> bool {
322        self.get_goal()
323            .map(|g| g.status.is_terminal())
324            .unwrap_or(false)
325    }
326}