Skip to main content

sac/tools/
goal.rs

1use serde_json::{json, Value};
2
3use crate::goal;
4use crate::goal::GoalStatus;
5use crate::tools::{require_str, ToolResult, ToolRuntime};
6use crate::types::{FunctionDef, ToolDefinition};
7
8pub fn get_goal_definition() -> ToolDefinition {
9    ToolDefinition {
10        def_type: "function".to_string(),
11        function: FunctionDef {
12            name: "get_goal".to_string(),
13            description: "Get the current goal objective, status, and progress. Returns the goal if one is set, or a message indicating no goal exists.".to_string(),
14            parameters: json!({
15                "type": "object",
16                "properties": {},
17                "required": []
18            }),
19        },
20    }
21}
22
23pub fn update_goal_definition() -> ToolDefinition {
24    ToolDefinition {
25        def_type: "function".to_string(),
26        function: FunctionDef {
27            name: "update_goal".to_string(),
28            description: "Update the existing goal.\n\
29                Use this tool only to mark the goal achieved or genuinely blocked.\n\
30                Set status to `complete` only when the objective has actually been achieved and no required work remains.\n\
31                Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns, counting the original/user-triggered turn and any automatic continuations, and the agent cannot make meaningful progress without user input or an external-state change.\n\
32                If the user resumes a goal that was previously marked `blocked`, treat the resumed run as a fresh blocked audit. If the same blocking condition then repeats for at least three consecutive resumed goal turns, set status to `blocked` again.\n\
33                Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`.\n\
34                Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification.\n\
35                Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work.\n\
36                You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system.\n\
37                When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user.".to_string(),
38            parameters: json!({
39                "type": "object",
40                "properties": {
41                    "status": {
42                        "type": "string",
43                        "enum": ["complete", "blocked"],
44                        "description": "Required. Set to `complete` only when the objective is achieved and no required work remains. Set to `blocked` only after the same blocking condition has recurred for at least three consecutive goal turns and the agent is at an impasse. After a previously blocked goal is resumed, the resumed run starts a fresh blocked audit."
45                    }
46                },
47                "required": ["status"]
48            }),
49        },
50    }
51}
52
53pub fn create_goal_definition() -> ToolDefinition {
54    ToolDefinition {
55        def_type: "function".to_string(),
56        function: FunctionDef {
57            name: "create_goal".to_string(),
58            description: "Create a goal only when explicitly requested by the user or system/developer instructions; do not infer goals from ordinary tasks.\n\
59                Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal exists; use update_goal only for status.".to_string(),
60            parameters: json!({
61                "type": "object",
62                "properties": {
63                    "objective": {
64                        "type": "string",
65                        "description": "Required. The concrete objective to start pursuing. This starts a new active goal when no goal exists or replaces the current goal when it is complete."
66                    },
67                    "token_budget": {
68                        "type": "integer",
69                        "description": "Positive token budget for the new goal. Omit unless explicitly requested."
70                    }
71                },
72                "required": ["objective"]
73            }),
74        },
75    }
76}
77
78pub async fn execute_create_goal(args: Value, runtime: &ToolRuntime) -> ToolResult {
79    let session_id = match require_session(runtime) {
80        Ok(sid) => sid.to_string(),
81        Err(result) => return result,
82    };
83    let store_path = runtime.store_path.clone();
84
85    let objective = match require_str(&args, "objective") {
86        Ok(s) => s.trim().to_string(),
87        Err(result) => return result,
88    };
89
90    if objective.is_empty() {
91        return ToolResult {
92            content: "Error: objective must not be empty".to_string(),
93            is_error: true,
94        };
95    }
96
97    let token_budget: Option<i64> = args
98        .get("token_budget")
99        .and_then(|v| v.as_i64());
100
101    if let Some(budget) = token_budget {
102        if budget <= 0 {
103            return ToolResult {
104                content: "Error: token_budget must be positive when provided".to_string(),
105                is_error: true,
106            };
107        }
108    }
109
110    let result = tokio::task::spawn_blocking(move || {
111        // Check for existing unfinished goal
112        if let Ok(Some(existing)) = goal::load_goal(&store_path, &session_id) {
113            if !existing.status.is_terminal() {
114                anyhow::bail!(
115                    "An unfinished goal already exists. Complete or clear the current goal before creating a new one."
116                );
117            }
118        }
119
120        let now = goal::now_utc();
121        let new_goal = goal::GoalState {
122            goal_id: goal::new_goal_id(),
123            objective: objective.clone(),
124            status: GoalStatus::Active,
125            tokens_used: 0,
126            time_used_seconds: 0,
127            token_budget,
128            created_at: now.clone(),
129            updated_at: now,
130        };
131        goal::save_goal(&store_path, &session_id, &new_goal)?;
132        Ok(new_goal)
133    })
134    .await;
135
136    match result {
137        Ok(Ok(g)) => {
138            let mut lines = vec![
139                format!("Goal created successfully."),
140                format!("Objective: {}", g.objective),
141                format!("Status: {}", g.status.label()),
142            ];
143            if let Some(budget) = g.token_budget {
144                lines.push(format!("Token budget: {}", budget));
145            }
146            lines.push(format!("Created: {}", g.created_at));
147            ToolResult {
148                content: lines.join("\n"),
149                is_error: false,
150            }
151        }
152        Ok(Err(e)) => ToolResult {
153            content: format!("Error: {}", e),
154            is_error: true,
155        },
156        Err(e) => ToolResult {
157            content: format!("Error: {}", e),
158            is_error: true,
159        },
160    }
161}
162
163pub async fn execute_get_goal(_args: Value, runtime: &ToolRuntime) -> ToolResult {
164    let session_id = match require_session(runtime) {
165        Ok(sid) => sid.to_string(),
166        Err(result) => return result,
167    };
168    let store_path = runtime.store_path.clone();
169
170    let result = tokio::task::spawn_blocking(move || {
171        goal::load_goal(&store_path, &session_id)
172    })
173    .await;
174
175    match result {
176        Ok(Ok(Some(g))) => {
177            let mut lines = vec![
178                format!("Goal objective: {}", g.objective),
179                format!("Status: {}", g.status.label()),
180                format!("Tokens used: {}", g.tokens_used),
181                format!("Time used: {}s", g.time_used_seconds),
182            ];
183            if let Some(budget) = g.token_budget {
184                let remaining = (budget - g.tokens_used).max(0);
185                lines.push(format!("Token budget: {}", budget));
186                lines.push(format!("Tokens remaining: {}", remaining));
187            }
188            match g.status {
189                goal::GoalStatus::UsageLimited => {
190                    lines.push("Note: Goal is paused because the session usage limit was exceeded. The user can resume with /goal resume.".to_string());
191                }
192                goal::GoalStatus::BudgetLimited => {
193                    lines.push("Note: Goal is stopped because its token budget has been exhausted. The user must raise the budget and resume.".to_string());
194                }
195                _ => {}
196            }
197            lines.push(format!("Created: {}", g.created_at));
198            lines.push(format!("Updated: {}", g.updated_at));
199            ToolResult {
200                content: lines.join("\n"),
201                is_error: false,
202            }
203        }
204        Ok(Ok(None)) => ToolResult {
205            content: "No goal is currently set.".to_string(),
206            is_error: false,
207        },
208        Ok(Err(e)) => ToolResult {
209            content: format!("Error loading goal: {}", e),
210            is_error: true,
211        },
212        Err(e) => ToolResult {
213            content: format!("Error: {}", e),
214            is_error: true,
215        },
216    }
217}
218
219pub async fn execute_update_goal(args: Value, runtime: &ToolRuntime) -> ToolResult {
220    let session_id = match require_session(runtime) {
221        Ok(sid) => sid.to_string(),
222        Err(result) => return result,
223    };
224    let store_path = runtime.store_path.clone();
225
226    let status_str = match require_str(&args, "status") {
227        Ok(s) => s,
228        Err(result) => return result,
229    };
230
231    let new_status = match goal::GoalStatus::from_str(&status_str) {
232        Some(s) if matches!(s, goal::GoalStatus::Complete | goal::GoalStatus::Blocked) => s,
233        _ => {
234            return ToolResult {
235                content: "Error: status must be 'complete' or 'blocked'".to_string(),
236                is_error: true,
237            }
238        }
239    };
240
241    let status_label = new_status.label().to_string();
242    let result = tokio::task::spawn_blocking(move || {
243        let mut g = match goal::load_goal(&store_path, &session_id)? {
244            Some(g) => g,
245            None => anyhow::bail!("No goal is currently set"),
246        };
247        g.status = new_status;
248        g.updated_at = goal::now_utc();
249        goal::save_goal(&store_path, &session_id, &g)?;
250        Ok(g)
251    })
252    .await;
253
254    match result {
255        Ok(Ok(g)) => {
256            let budget_info = match g.token_budget {
257                Some(budget) => format!(
258                    "\nTokens used: {} of {} budget",
259                    g.tokens_used, budget
260                ),
261                None => format!("\nTokens used: {}", g.tokens_used),
262            };
263            ToolResult {
264                content: format!(
265                    "Goal updated to '{}'. Objective: {}\nTime used: {}s{}",
266                    status_label, g.objective, g.time_used_seconds, budget_info
267                ),
268                is_error: false,
269            }
270        }
271        Ok(Err(e)) => ToolResult {
272            content: format!("Error: {}", e),
273            is_error: true,
274        },
275        Err(e) => ToolResult {
276            content: format!("Error: {}", e),
277            is_error: true,
278        },
279    }
280}
281
282fn require_session(runtime: &ToolRuntime) -> Result<&str, ToolResult> {
283    runtime.session_id.as_deref().ok_or_else(|| ToolResult {
284        content: "Error: goal tools require an active session".to_string(),
285        is_error: true,
286    })
287}