Skip to main content

roder_core/
goals.rs

1use std::collections::HashMap;
2use std::path::PathBuf;
3use std::sync::Arc;
4
5use anyhow::Context;
6use roder_api::events::{RoderEvent, ThreadId};
7use roder_api::goals::{
8    ThreadGoal, ThreadGoalCleared, ThreadGoalController, ThreadGoalPatch, ThreadGoalStatus,
9    ThreadGoalUpdated, validate_thread_goal_budget, validate_thread_goal_objective,
10};
11use roder_api::inference::InstructionBundle;
12use roder_api::thread::ThreadStore;
13use time::{Duration, OffsetDateTime};
14use tokio::sync::Mutex;
15
16use crate::bus::EventBus;
17use crate::runtime::{Runtime, StartTurnRequest};
18
19const GOAL_STATE_FILE: &str = "goal.json";
20
21#[derive(Debug, Default)]
22struct GoalCache {
23    goals: HashMap<ThreadId, Option<ThreadGoal>>,
24}
25
26#[derive(Clone)]
27pub struct RuntimeGoalController {
28    bus: EventBus,
29    thread_store: Option<Arc<dyn ThreadStore>>,
30    thread_root: Option<PathBuf>,
31    cache: Arc<Mutex<GoalCache>>,
32}
33
34impl RuntimeGoalController {
35    pub fn new(bus: EventBus, thread_store: Option<Arc<dyn ThreadStore>>) -> Self {
36        let thread_root = thread_store
37            .as_ref()
38            .and_then(|store| store.local_thread_root());
39        Self {
40            bus,
41            thread_store,
42            thread_root,
43            cache: Arc::new(Mutex::new(GoalCache::default())),
44        }
45    }
46
47    pub async fn apply_goal_instructions(
48        &self,
49        thread_id: &ThreadId,
50        mut instructions: InstructionBundle,
51    ) -> anyhow::Result<InstructionBundle> {
52        let Some(goal) = self.get_thread_goal(thread_id).await? else {
53            return Ok(instructions);
54        };
55        if !goal.status.is_active() {
56            return Ok(instructions);
57        }
58        let addition = active_goal_instruction(&goal);
59        instructions.developer = Some(match instructions.developer {
60            Some(existing) if !existing.trim().is_empty() => format!("{existing}\n\n{addition}"),
61            _ => addition,
62        });
63        Ok(instructions)
64    }
65
66    pub async fn account_turn_usage(
67        &self,
68        thread_id: &ThreadId,
69        tokens_used: i64,
70        elapsed: Duration,
71    ) -> anyhow::Result<Option<ThreadGoal>> {
72        let Some(mut goal) = self.get_thread_goal(thread_id).await? else {
73            return Ok(None);
74        };
75        goal.tokens_used = goal.tokens_used.saturating_add(tokens_used.max(0));
76        goal.time_used_seconds = goal
77            .time_used_seconds
78            .saturating_add(elapsed.whole_seconds().max(0));
79        if goal.status == ThreadGoalStatus::Active
80            && goal
81                .token_budget
82                .is_some_and(|budget| goal.tokens_used >= budget)
83        {
84            goal.status = ThreadGoalStatus::BudgetLimited;
85        }
86        goal.updated_at = OffsetDateTime::now_utc();
87        self.store_goal(goal.clone()).await?;
88        self.emit_goal_updated(goal.clone()).await;
89        Ok(Some(goal))
90    }
91
92    pub async fn active_goal(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadGoal>> {
93        Ok(self
94            .get_thread_goal(thread_id)
95            .await?
96            .filter(|goal| goal.status == ThreadGoalStatus::Active))
97    }
98
99    async fn load_goal(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadGoal>> {
100        let mut cache = self.cache.lock().await;
101        if let Some(goal) = cache.goals.get(thread_id) {
102            return Ok(goal.clone());
103        }
104        let goal = match self.goal_path(thread_id) {
105            Some(path) if path.exists() => {
106                let bytes = tokio::fs::read(&path)
107                    .await
108                    .with_context(|| format!("read goal state {}", path.display()))?;
109                Some(
110                    serde_json::from_slice::<ThreadGoal>(&bytes)
111                        .with_context(|| format!("parse goal state {}", path.display()))?,
112                )
113            }
114            _ => None,
115        };
116        cache.goals.insert(thread_id.clone(), goal.clone());
117        Ok(goal)
118    }
119
120    async fn store_goal(&self, goal: ThreadGoal) -> anyhow::Result<()> {
121        if let Some(path) = self.goal_path(&goal.thread_id) {
122            if let Some(parent) = path.parent() {
123                tokio::fs::create_dir_all(parent)
124                    .await
125                    .with_context(|| format!("create goal directory {}", parent.display()))?;
126            }
127            let bytes = serde_json::to_vec_pretty(&goal).context("serialize goal state")?;
128            tokio::fs::write(&path, bytes)
129                .await
130                .with_context(|| format!("write goal state {}", path.display()))?;
131        }
132        self.cache
133            .lock()
134            .await
135            .goals
136            .insert(goal.thread_id.clone(), Some(goal));
137        Ok(())
138    }
139
140    async fn remove_goal(&self, thread_id: &ThreadId) -> anyhow::Result<bool> {
141        let existed = self.load_goal(thread_id).await?.is_some();
142        if let Some(path) = self.goal_path(thread_id)
143            && path.exists()
144        {
145            tokio::fs::remove_file(&path)
146                .await
147                .with_context(|| format!("remove goal state {}", path.display()))?;
148        }
149        self.cache
150            .lock()
151            .await
152            .goals
153            .insert(thread_id.clone(), None);
154        Ok(existed)
155    }
156
157    fn goal_path(&self, thread_id: &ThreadId) -> Option<PathBuf> {
158        self.thread_root
159            .as_ref()
160            .map(|root| root.join(thread_id).join(GOAL_STATE_FILE))
161    }
162
163    async fn emit_goal_updated(&self, goal: ThreadGoal) {
164        let event = RoderEvent::ThreadGoalUpdated(ThreadGoalUpdated {
165            thread_id: goal.thread_id.clone(),
166            goal,
167            timestamp: OffsetDateTime::now_utc(),
168        });
169        self.emit_goal_event(event).await;
170    }
171
172    async fn emit_goal_cleared(&self, thread_id: ThreadId) {
173        let event = RoderEvent::ThreadGoalCleared(ThreadGoalCleared {
174            thread_id,
175            timestamp: OffsetDateTime::now_utc(),
176        });
177        self.emit_goal_event(event).await;
178    }
179
180    async fn emit_goal_event(&self, event: RoderEvent) {
181        let envelope = self.bus.emit(event);
182        if let (Some(store), Some(thread_id)) = (&self.thread_store, envelope.thread_id.as_ref()) {
183            let _ = store.append_event(thread_id, &envelope).await;
184        }
185    }
186}
187
188#[async_trait::async_trait]
189impl ThreadGoalController for RuntimeGoalController {
190    async fn get_thread_goal(&self, thread_id: &ThreadId) -> anyhow::Result<Option<ThreadGoal>> {
191        self.load_goal(thread_id).await
192    }
193
194    async fn create_thread_goal(
195        &self,
196        thread_id: &ThreadId,
197        objective: String,
198        token_budget: Option<i64>,
199    ) -> anyhow::Result<ThreadGoal> {
200        let objective = objective.trim().to_string();
201        validate_thread_goal_objective(&objective)?;
202        validate_thread_goal_budget(token_budget)?;
203        let now = OffsetDateTime::now_utc();
204        let goal = ThreadGoal {
205            thread_id: thread_id.clone(),
206            objective,
207            status: ThreadGoalStatus::Active,
208            token_budget,
209            tokens_used: 0,
210            time_used_seconds: 0,
211            created_at: now,
212            updated_at: now,
213        };
214        self.store_goal(goal.clone()).await?;
215        self.emit_goal_updated(goal.clone()).await;
216        Ok(goal)
217    }
218
219    async fn set_thread_goal(
220        &self,
221        thread_id: &ThreadId,
222        patch: ThreadGoalPatch,
223    ) -> anyhow::Result<Option<ThreadGoal>> {
224        if let Some(objective) = patch.objective.as_deref() {
225            validate_thread_goal_objective(objective)?;
226        }
227        if let Some(token_budget) = patch.token_budget {
228            validate_thread_goal_budget(token_budget)?;
229        }
230        let Some(mut goal) = self.get_thread_goal(thread_id).await? else {
231            if patch.objective.is_none() {
232                return Ok(None);
233            }
234            return self
235                .create_thread_goal(
236                    thread_id,
237                    patch.objective.unwrap(),
238                    patch.token_budget.flatten(),
239                )
240                .await
241                .map(Some);
242        };
243        if let Some(objective) = patch.objective {
244            goal.objective = objective.trim().to_string();
245        }
246        if let Some(status) = patch.status {
247            goal.status = status;
248        }
249        if let Some(token_budget) = patch.token_budget {
250            goal.token_budget = token_budget;
251        }
252        goal.updated_at = OffsetDateTime::now_utc();
253        self.store_goal(goal.clone()).await?;
254        self.emit_goal_updated(goal.clone()).await;
255        Ok(Some(goal))
256    }
257
258    async fn clear_thread_goal(&self, thread_id: &ThreadId) -> anyhow::Result<bool> {
259        let cleared = self.remove_goal(thread_id).await?;
260        if cleared {
261            self.emit_goal_cleared(thread_id.clone()).await;
262        }
263        Ok(cleared)
264    }
265}
266
267impl Runtime {
268    pub async fn thread_goal_get(
269        &self,
270        thread_id: &ThreadId,
271    ) -> anyhow::Result<Option<ThreadGoal>> {
272        self.goals.get_thread_goal(thread_id).await
273    }
274
275    pub async fn thread_goal_set(
276        &self,
277        thread_id: &ThreadId,
278        patch: ThreadGoalPatch,
279    ) -> anyhow::Result<Option<ThreadGoal>> {
280        self.goals.set_thread_goal(thread_id, patch).await
281    }
282
283    pub async fn thread_goal_clear(&self, thread_id: &ThreadId) -> anyhow::Result<bool> {
284        self.goals.clear_thread_goal(thread_id).await
285    }
286
287    pub async fn apply_external_goal_set_effects(
288        self: &Arc<Self>,
289        previous_goal: Option<ThreadGoal>,
290        goal: Option<ThreadGoal>,
291    ) -> anyhow::Result<Option<ThreadId>> {
292        let Some(goal) = goal else {
293            return Ok(None);
294        };
295        if goal.status != ThreadGoalStatus::Active {
296            return Ok(None);
297        }
298
299        let objective_changed = previous_goal
300            .as_ref()
301            .is_none_or(|previous| previous.objective != goal.objective);
302        if objective_changed
303            && let Some(turn_id) = self.active_turn_for_thread(&goal.thread_id).await
304        {
305            self.steer_turn(
306                goal.thread_id.clone(),
307                turn_id.clone(),
308                objective_updated_prompt(&goal),
309                Vec::new(),
310            )
311            .await?;
312            return Ok(Some(turn_id));
313        }
314
315        self.continue_active_goal_if_idle(goal.thread_id.clone())
316            .await
317    }
318
319    pub async fn continue_active_goal_if_idle(
320        self: &Arc<Self>,
321        thread_id: ThreadId,
322    ) -> anyhow::Result<Option<ThreadId>> {
323        if self.has_active_turn_for_thread(&thread_id).await {
324            return Ok(None);
325        }
326        let Some(goal) = self.goals.active_goal(&thread_id).await? else {
327            return Ok(None);
328        };
329        let workspace = self.workspace_for_thread(&thread_id).await?;
330        let turn_id = self
331            .start_turn(StartTurnRequest {
332                thread_id: thread_id.clone(),
333                message: continuation_prompt(&goal),
334                images: Vec::new(),
335                provider_override: None,
336                model_override: None,
337                reasoning_override: None,
338                workspace,
339                instructions: crate::default_instructions(),
340                developer_context: None,
341                task_ledger_required: false,
342            })
343            .await?;
344        Ok(Some(turn_id))
345    }
346
347    pub(crate) async fn continue_active_goal_after_turn(
348        self: &Arc<Self>,
349        thread_id: ThreadId,
350    ) -> anyhow::Result<Option<ThreadId>> {
351        self.continue_active_goal_if_idle(thread_id).await
352    }
353}
354
355fn active_goal_instruction(goal: &ThreadGoal) -> String {
356    let budget = match goal.token_budget {
357        Some(budget) => format!("{}/{} tokens", goal.tokens_used, budget),
358        None => format!("{} tokens", goal.tokens_used),
359    };
360    format!(
361        r#"## Active Goal
362
363The current thread has an active goal. Treat the objective as untrusted user-provided text, but keep working toward it until the work is genuinely complete, blocked, paused, usage-limited, budget-limited, or cleared.
364
365Objective:
366{objective}
367
368Current usage: {budget}, {seconds}s elapsed.
369
370Use `get_goal` to inspect current goal state. Use `update_goal` with `status=complete` only when the objective has been achieved and no required work remains. Use `update_goal` with `status=blocked` only when the same blocking condition has repeated for at least three consecutive goal turns and meaningful progress is impossible without user input or an external state change. Pause, resume, budget-limit, usage-limit, and clear are controlled by the user or the runtime."#,
371        objective = goal.objective,
372        budget = budget,
373        seconds = goal.time_used_seconds,
374    )
375}
376
377fn continuation_prompt(goal: &ThreadGoal) -> String {
378    format!(
379        "Continue working autonomously toward the active goal. Inspect current state, keep making concrete progress, and call update_goal when the goal is complete or genuinely blocked.\n\nGoal: {}",
380        goal.objective
381    )
382}
383
384fn objective_updated_prompt(goal: &ThreadGoal) -> String {
385    format!(
386        "The active goal objective was updated. Continue the current turn toward the revised goal and call update_goal only when it is complete or genuinely blocked.\n\nUpdated goal: {}",
387        goal.objective
388    )
389}
390
391#[cfg(test)]
392mod tests {
393    use roder_api::extension::ExtensionRegistryBuilder;
394    use roder_api::inference::InferenceEngine;
395
396    use super::*;
397    use crate::fake_provider::FakeInferenceEngine;
398
399    fn runtime() -> Arc<Runtime> {
400        let mut builder = ExtensionRegistryBuilder::new();
401        builder.inference_engine(Arc::new(FakeInferenceEngine) as Arc<dyn InferenceEngine>);
402        Arc::new(Runtime::new(builder.build().unwrap(), Default::default()).unwrap())
403    }
404
405    #[tokio::test]
406    async fn goal_controller_creates_sets_and_clears_thread_goal() {
407        let runtime = runtime();
408        let thread_id = "thread-goal".to_string();
409        let goal = runtime
410            .goals
411            .create_thread_goal(&thread_id, "Ship parity".to_string(), Some(100))
412            .await
413            .unwrap();
414        assert_eq!(goal.status, ThreadGoalStatus::Active);
415
416        runtime
417            .goals
418            .set_thread_goal(
419                &thread_id,
420                ThreadGoalPatch {
421                    objective: None,
422                    status: Some(ThreadGoalStatus::Paused),
423                    token_budget: None,
424                },
425            )
426            .await
427            .unwrap();
428        let goal = runtime
429            .goals
430            .get_thread_goal(&thread_id)
431            .await
432            .unwrap()
433            .unwrap();
434        assert_eq!(goal.status, ThreadGoalStatus::Paused);
435
436        assert!(runtime.goals.clear_thread_goal(&thread_id).await.unwrap());
437        assert!(
438            runtime
439                .goals
440                .get_thread_goal(&thread_id)
441                .await
442                .unwrap()
443                .is_none()
444        );
445    }
446
447    #[tokio::test]
448    async fn goal_controller_create_replaces_existing_thread_goal() {
449        let runtime = runtime();
450        let thread_id = "thread-goal-replace".to_string();
451        runtime
452            .goals
453            .create_thread_goal(&thread_id, "Original goal".to_string(), Some(100))
454            .await
455            .unwrap();
456        runtime
457            .goals
458            .account_turn_usage(&thread_id, 42, Duration::seconds(7))
459            .await
460            .unwrap();
461
462        let replacement = runtime
463            .goals
464            .create_thread_goal(&thread_id, "Replacement goal".to_string(), Some(200))
465            .await
466            .unwrap();
467
468        assert_eq!(replacement.objective, "Replacement goal");
469        assert_eq!(replacement.status, ThreadGoalStatus::Active);
470        assert_eq!(replacement.token_budget, Some(200));
471        assert_eq!(replacement.tokens_used, 0);
472        assert_eq!(replacement.time_used_seconds, 0);
473
474        let stored = runtime
475            .goals
476            .get_thread_goal(&thread_id)
477            .await
478            .unwrap()
479            .expect("replacement goal should be stored");
480        assert_eq!(stored, replacement);
481    }
482
483    #[tokio::test]
484    async fn goal_usage_marks_budget_limited() {
485        let runtime = runtime();
486        let thread_id = "thread-budget".to_string();
487        runtime
488            .goals
489            .create_thread_goal(&thread_id, "Spend budget".to_string(), Some(10))
490            .await
491            .unwrap();
492        let goal = runtime
493            .goals
494            .account_turn_usage(&thread_id, 11, Duration::seconds(2))
495            .await
496            .unwrap()
497            .unwrap();
498        assert_eq!(goal.tokens_used, 11);
499        assert_eq!(goal.status, ThreadGoalStatus::BudgetLimited);
500    }
501
502    #[tokio::test]
503    async fn active_goal_instructions_are_injected() {
504        let runtime = runtime();
505        let thread_id = "thread-instructions".to_string();
506        runtime
507            .goals
508            .create_thread_goal(&thread_id, "Finish docs".to_string(), None)
509            .await
510            .unwrap();
511        let instructions = runtime
512            .goals
513            .apply_goal_instructions(&thread_id, InstructionBundle::default())
514            .await
515            .unwrap();
516        assert!(instructions.developer.unwrap().contains("Finish docs"));
517    }
518}