| 1 | //! Long-running session goals (opencode's `/goal` plugin). |
| 2 | //! |
| 3 | //! A goal is one per-session objective the agent keeps working on across |
| 4 | //! multiple turns. `create_goal` / `get_goal` / `update_goal` are offered to |
| 5 | //! the model as tools; `/goal <objective>` sets one up, `/goal pause|resume| |
| 6 | //! clear|status` manages it, and whenever the session goes idle while the |
| 7 | //! goal is still `active` the agent is re-prompted with a continuation |
| 8 | //! reminder until the goal is `complete`, `blocked`, `paused`, or `cleared`. |
| 9 | |
| 10 | use crate::db; |
| 11 | |
| 12 | /// Statuses the model may transition a goal to on its own. |
| 13 | pub const STATUS_ACTIVE: |
| 14 | pub const STATUS_PAUSED: |
| 15 | pub const STATUS_BLOCKED: |
| 16 | pub const STATUS_COMPLETE: |
| 17 | |
| 18 | /// The message `/goal <objective>` hands to the agent. Mirrors opencode's |
| 19 | /// plugin template: the model is expected to call `create_goal` (or `get_goal` |
| 20 | /// when one already exists) and then work autonomously. |
| 21 | pub fn goal_template(objective |
| 22 | format!( |
| 23 | "I want you to achieve this goal: {objective}\n\n |
| 24 | You have get_goal, create_goal and update_goal tools available. Work autonomously across \ |
| 25 | multiple turns.\n\n\ |
| 26 | If a goal is already set for this session, use get_goal to check it and continue working." |
| 27 | ) |
| 28 | } |
| 29 | |
| 30 | /// True while the session should keep auto-continuing toward the goal. |
| 31 | pub fn is_continue_needed(goal |
| 32 | goal.status == STATUS_ACTIVE |
| 33 | } |
| 34 | |
| 35 | /// The reminder injected when the session goes idle with an active goal. |
| 36 | pub fn continuation_prompt(goal |
| 37 | format!( |
| 38 | "<system-reminder>\n\ |
| 39 | Continue working toward your goal. Do not acknowledge this reminder.\n\n\ |
| 40 | <objective>{}</objective>\n\n\ |
| 41 | Status: {}\n\ |
| 42 | Time used: {}\n\ |
| 43 | Iterations: {}\n\n\ |
| 44 | Before deciding the goal is achieved, verify every requirement.\n\ |
| 45 | Update the goal to \"complete\" only when you are certain.\n\ |
| 46 | </system-reminder>", |
| 47 | goal.objective, |
| 48 | goal.status, |
| 49 | fmt_duration(goal.time_used_seconds), |
| 50 | goal.iteration_count, |
| 51 | ) |
| 52 | } |
| 53 | |
| 54 | /// Human summary of a goal (`/goal status`, bare `/goal`). |
| 55 | pub fn render(goal |
| 56 | format!( |
| 57 | "Objective: {}\nStatus: {}\n |
| 58 | goal.objective, |
| 59 | goal.status, |
| 60 | goal.iteration_count, |
| 61 | fmt_duration(goal.time_used_seconds), |
| 62 | ) |
| 63 | } |
| 64 | |
| 65 | /// Format a duration in seconds the way opencode does: `12s`, `2m 5s`, `1h 2m`. |
| 66 | pub fn fmt_duration(seconds |
| 67 | if seconds < 60 |
| 68 | format!("{seconds} |
| 69 | } else if seconds |
| 70 | format!("{}m {} |
| 71 | } else { |
| 72 | format!("{}h {} |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// Unix epoch milliseconds (opencode's `Date.now()` for goal timestamps). |
| 77 | pub fn now_millis() |
| 78 | std::time::SystemTime |
| 79 | .duration_since(std::time |
| 80 | .map(|d| |
| 81 | .unwrap_or(0) |
| 82 | } |
| 83 | |
| 84 | #[cfg(test)] |
| 85 | mod tests { |
| 86 | use super::*; |
| 87 | |
| 88 | fn goal(status: |
| 89 | db::Goal |
| 90 | goal_id: "g-1". |
| 91 | objective: "fix the login". |
| 92 | status: status.into |
| 93 | time_used_seconds: 0, |
| 94 | iteration_count: 0, |
| 95 | time_created: 1, |
| 96 | time_updated: 1, |
| 97 | } |
| 98 | } |
| 99 | |
| 100 | #[test] |
| 101 | fn only_active_goals_continue() { |
| 102 | assert!(is_continue_needed( |
| 103 | for s in [ |
| 104 | STATUS_PAUSED, |
| 105 | STATUS_BLOCKED, |
| 106 | "usage_limited", |
| 107 | STATUS_COMPLETE, |
| 108 | ] { |
| 109 | assert!(!is_continue_needed |
| 110 | } |
| 111 | } |
| 112 | |
| 113 | #[test] |
| 114 | fn goal_template_mentions_objective_and_tools() { |
| 115 | let t = |
| 116 | assert!(t.contains |
| 117 | assert!(t.contains |
| 118 | assert!(t.contains |
| 119 | } |
| 120 | |
| 121 | #[test] |
| 122 | fn continuation_prompt_is_a_system_reminder() { |
| 123 | let p = |
| 124 | assert!(p.contains |
| 125 | assert!(p.contains |
| 126 | assert!(p.contains |
| 127 | assert!(p.contains |
| 128 | assert!(p.contains |
| 129 | } |
| 130 | |
| 131 | #[test] |
| 132 | fn continuation_prompt_shows_time_and_iterations() { |
| 133 | let g = |
| 134 | time_used_seconds: 125, |
| 135 | iteration_count: 3, |
| 136 | ..goal("active") |
| 137 | }; |
| 138 | let p = |
| 139 | assert!(p.contains |
| 140 | assert!(p.contains |
| 141 | } |
| 142 | |
| 143 | #[test] |
| 144 | fn render_lists_objective_and_metrics() { |
| 145 | let g = |
| 146 | status: "paused". |
| 147 | time_used_seconds: 3600, |
| 148 | iteration_count: 2, |
| 149 | ..goal("active") |
| 150 | }; |
| 151 | let r = |
| 152 | assert!(r.contains |
| 153 | assert!(r.contains |
| 154 | assert!(r.contains |
| 155 | assert!(r.contains |
| 156 | } |
| 157 | |
| 158 | #[test] |
| 159 | fn fmt_duration_matches_opencode() { |
| 160 | assert_eq!(fmt_duration( |
| 161 | assert_eq!(fmt_duration( |
| 162 | assert_eq!(fmt_duration( |
| 163 | assert_eq!(fmt_duration( |
| 164 | assert_eq!(fmt_duration( |
| 165 | assert_eq!(fmt_duration( |
| 166 | } |
| 167 | } |