Skip to main content

navi_core/goal/
tools.rs

1use crate::event::AgentEvent;
2use crate::goal::runtime::GoalRuntimeHandle;
3use crate::goal::types::{GoalStatus, SessionGoal};
4use crate::tool::{
5    Tool, ToolDefinition, ToolInvocation, ToolInvocationContext, ToolKind, ToolMetadata, ToolResult,
6};
7use async_trait::async_trait;
8use serde_json::{Value, json};
9use std::sync::Arc;
10
11// ── Helpers ──────────────────────────────────────────────────
12
13fn make_result(invocation_id: &str, ok: bool, output: Value) -> ToolResult {
14    ToolResult {
15        invocation_id: invocation_id.to_string(),
16        ok,
17        output,
18    }
19}
20
21fn goal_with_status(goal: &SessionGoal, status: GoalStatus) -> SessionGoal {
22    let mut new_goal = goal.clone();
23    new_goal.status = status;
24    new_goal.updated_at = crate::session::current_unix_timestamp();
25    new_goal
26}
27
28fn limit_short_description(value: &str) -> String {
29    value.trim().chars().take(40).collect()
30}
31
32/// Notify the session event stream so runtime `record_event` fans out GoalUpdated.
33fn emit_goal_updated(context: &ToolInvocationContext, goal: &SessionGoal) {
34    if let Some(tx) = &context.event_tx {
35        let _ = tx.send(AgentEvent::GoalUpdated {
36            session_id: goal.session_id.clone(),
37            goal_id: goal.goal_id.as_str().to_string(),
38            objective: goal.objective.clone(),
39            short_description: goal.short_description.clone(),
40            status: goal.status,
41            tokens_used: goal.tokens_used,
42            token_budget: goal.token_budget,
43        });
44    }
45}
46
47fn is_unfinished(status: GoalStatus) -> bool {
48    !status.is_terminal()
49}
50
51// ── get_goal ─────────────────────────────────────────────────
52
53pub struct GetGoalTool {
54    runtime: Arc<GoalRuntimeHandle>,
55}
56
57impl GetGoalTool {
58    pub fn new(runtime: Arc<GoalRuntimeHandle>) -> Self {
59        Self { runtime }
60    }
61
62    pub fn definition_static() -> ToolDefinition {
63        ToolDefinition {
64            name: "get_goal".to_string(),
65            description: "Get the current goal for this thread, including status, budgets, token \
66                 and elapsed-time usage, and remaining token budget."
67                .to_string(),
68            kind: ToolKind::Read,
69            input_schema: json!({
70                "type": "object",
71                "properties": {},
72                "additionalProperties": false
73            }),
74            metadata: ToolMetadata {
75                tags: vec!["goal".to_string(), "session".to_string()],
76                capabilities: vec!["goal.read".to_string()],
77                exposure: crate::tool::ToolExposure::Direct,
78                ..ToolMetadata::default()
79            },
80        }
81    }
82}
83
84#[async_trait]
85impl Tool for GetGoalTool {
86    fn definition(&self) -> ToolDefinition {
87        Self::definition_static()
88    }
89
90    async fn invoke(&self, invocation: ToolInvocation) -> anyhow::Result<ToolResult> {
91        let result = match self.runtime.get_goal() {
92            Some(goal) => make_result(
93                &invocation.id,
94                true,
95                json!({
96                    "goal": {
97                        "goal_id": goal.goal_id.as_str(),
98                        "objective": goal.objective,
99                        "short_description": goal.short_description,
100                        "status": goal.status.as_str(),
101                        "tokens_used": goal.tokens_used,
102                        "token_budget": goal.token_budget,
103                        "time_used_seconds": goal.time_used_seconds,
104                        "created_at": goal.created_at,
105                        "updated_at": goal.updated_at
106                    },
107                    "remaining_tokens": goal.remaining_budget(),
108                }),
109            ),
110            None => make_result(
111                &invocation.id,
112                true,
113                json!({
114                    "goal": null,
115                    "message": "No goal is currently set for this session."
116                }),
117            ),
118        };
119        Ok(result)
120    }
121
122    async fn invoke_with_context(
123        &self,
124        invocation: ToolInvocation,
125        _context: ToolInvocationContext,
126    ) -> anyhow::Result<ToolResult> {
127        self.invoke(invocation).await
128    }
129}
130
131// ── create_goal ──────────────────────────────────────────────
132
133pub struct CreateGoalTool {
134    runtime: Arc<GoalRuntimeHandle>,
135}
136
137impl CreateGoalTool {
138    pub fn new(runtime: Arc<GoalRuntimeHandle>) -> Self {
139        Self { runtime }
140    }
141
142    pub fn definition_static() -> ToolDefinition {
143        ToolDefinition {
144            name: "create_goal".to_string(),
145            description: format!(
146                "Create a goal only when explicitly requested by the user or system/developer \
147instructions; do not infer goals from ordinary tasks.\n\
148Set token_budget only when an explicit token budget is requested. Fails if an unfinished goal \
149exists; use `{UPDATE_GOAL}` only for status.",
150                UPDATE_GOAL = "update_goal"
151            ),
152            kind: ToolKind::Write,
153            input_schema: json!({
154                "type": "object",
155                "properties": {
156                    "objective": {
157                        "type": "string",
158                        "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."
159                    },
160                    "short_description": {
161                        "type": "string",
162                        "maxLength": 40,
163                        "description": "Optional short UI label (max 40 characters)."
164                    },
165                    "token_budget": {
166                        "type": "integer",
167                        "description": "Positive token budget for the new goal. Omit unless explicitly requested."
168                    }
169                },
170                "required": ["objective"],
171                "additionalProperties": false
172            }),
173            metadata: ToolMetadata {
174                tags: vec!["goal".to_string(), "session".to_string()],
175                capabilities: vec!["goal.create".to_string()],
176                exposure: crate::tool::ToolExposure::Direct,
177                ..ToolMetadata::default()
178            },
179        }
180    }
181}
182
183#[async_trait]
184impl Tool for CreateGoalTool {
185    fn definition(&self) -> ToolDefinition {
186        Self::definition_static()
187    }
188
189    async fn invoke(&self, invocation: ToolInvocation) -> anyhow::Result<ToolResult> {
190        self.invoke_with_context(invocation, ToolInvocationContext::default())
191            .await
192    }
193
194    async fn invoke_with_context(
195        &self,
196        invocation: ToolInvocation,
197        context: ToolInvocationContext,
198    ) -> anyhow::Result<ToolResult> {
199        let args = invocation.input.clone();
200
201        let objective = args
202            .get("objective")
203            .and_then(|v| v.as_str())
204            .map(str::trim)
205            .unwrap_or("")
206            .to_string();
207        if objective.is_empty() {
208            return Ok(make_result(
209                &invocation.id,
210                false,
211                json!({"error": "objective is required"}),
212            ));
213        }
214
215        if let Some(existing) = self.runtime.get_goal() {
216            if is_unfinished(existing.status) {
217                return Ok(make_result(
218                    &invocation.id,
219                    false,
220                    json!({
221                        "error": "cannot create a new goal because this thread has an unfinished goal; complete the existing goal first",
222                        "existing_goal_id": existing.goal_id.as_str(),
223                        "existing_status": existing.status.as_str(),
224                    }),
225                ));
226            }
227        }
228
229        let token_budget = args.get("token_budget").and_then(|v| v.as_i64());
230        if let Some(budget) = token_budget {
231            if budget <= 0 {
232                return Ok(make_result(
233                    &invocation.id,
234                    false,
235                    json!({"error": "token_budget must be a positive integer when set"}),
236                ));
237            }
238        }
239
240        let short_description = args
241            .get("short_description")
242            .and_then(|v| v.as_str())
243            .map(limit_short_description);
244
245        // Always create a fresh goal id (do not mutate a terminal goal in place).
246        let goal = self
247            .runtime
248            .create_new_goal(objective, short_description, token_budget);
249        self.runtime.set_auto_continue(true);
250        emit_goal_updated(&context, &goal);
251
252        Ok(make_result(
253            &invocation.id,
254            true,
255            json!({
256                "goal": {
257                    "goal_id": goal.goal_id.as_str(),
258                    "objective": goal.objective,
259                    "short_description": goal.short_description,
260                    "status": goal.status.as_str(),
261                    "token_budget": goal.token_budget,
262                    "tokens_used": goal.tokens_used,
263                },
264                "message": "Goal created successfully. The agent will auto-continue working toward this goal."
265            }),
266        ))
267    }
268}
269
270// ── update_goal ──────────────────────────────────────────────
271
272pub struct UpdateGoalTool {
273    runtime: Arc<GoalRuntimeHandle>,
274}
275
276impl UpdateGoalTool {
277    pub fn new(runtime: Arc<GoalRuntimeHandle>) -> Self {
278        Self { runtime }
279    }
280
281    pub fn definition_static() -> ToolDefinition {
282        ToolDefinition {
283            name: "update_goal".to_string(),
284            description: r#"Update the existing goal.
285Use this tool only to mark the goal achieved or genuinely blocked.
286Set status to `complete` only when the objective has actually been achieved and no required work remains.
287Set 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.
288If 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.
289Once the blocked threshold is satisfied, do not keep reporting that you are still blocked while leaving the goal active; set status to `blocked`.
290Do not use `blocked` merely because the work is hard, slow, uncertain, incomplete, or would benefit from clarification.
291Do not mark a goal complete merely because its budget is nearly exhausted or because you are stopping work.
292You cannot use this tool to pause, resume, budget-limit, or usage-limit a goal; those status changes are controlled by the user or system.
293When marking a budgeted goal achieved with status `complete`, report the final token usage from the tool result to the user."#
294                .to_string(),
295            kind: ToolKind::Write,
296            input_schema: json!({
297                "type": "object",
298                "properties": {
299                    "status": {
300                        "type": "string",
301                        "enum": ["complete", "blocked"],
302                        "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."
303                    }
304                },
305                "required": ["status"],
306                "additionalProperties": false
307            }),
308            metadata: ToolMetadata {
309                tags: vec!["goal".to_string(), "session".to_string()],
310                capabilities: vec!["goal.update".to_string()],
311                exposure: crate::tool::ToolExposure::Direct,
312                ..ToolMetadata::default()
313            },
314        }
315    }
316}
317
318#[async_trait]
319impl Tool for UpdateGoalTool {
320    fn definition(&self) -> ToolDefinition {
321        Self::definition_static()
322    }
323
324    async fn invoke(&self, invocation: ToolInvocation) -> anyhow::Result<ToolResult> {
325        self.invoke_with_context(invocation, ToolInvocationContext::default())
326            .await
327    }
328
329    async fn invoke_with_context(
330        &self,
331        invocation: ToolInvocation,
332        context: ToolInvocationContext,
333    ) -> anyhow::Result<ToolResult> {
334        let args = invocation.input.clone();
335        let status = args.get("status").and_then(|v| v.as_str()).unwrap_or("");
336
337        let goal = match self.runtime.get_goal() {
338            Some(g) => g,
339            None => {
340                return Ok(make_result(
341                    &invocation.id,
342                    false,
343                    json!({"error": "cannot update goal because this thread has no goal"}),
344                ));
345            }
346        };
347
348        match status {
349            "complete" => {
350                let updated = goal_with_status(&goal, GoalStatus::Complete);
351                self.runtime.update_goal(updated.clone());
352                self.runtime.set_auto_continue(false);
353                emit_goal_updated(&context, &updated);
354                Ok(make_result(
355                    &invocation.id,
356                    true,
357                    json!({
358                        "goal": {
359                            "goal_id": updated.goal_id.as_str(),
360                            "objective": updated.objective,
361                            "status": "complete",
362                            "tokens_used": updated.tokens_used,
363                            "token_budget": updated.token_budget,
364                            "time_used_seconds": updated.time_used_seconds,
365                        },
366                        "remaining_tokens": updated.remaining_budget(),
367                        "completion_budget_report": updated.token_budget.map(|b| {
368                            format!(
369                                "Goal complete. Tokens used: {} / budget: {}.",
370                                updated.tokens_used, b
371                            )
372                        }),
373                        "message": "Goal marked as complete."
374                    }),
375                ))
376            }
377            "blocked" => {
378                let updated = goal_with_status(&goal, GoalStatus::Blocked);
379                self.runtime.update_goal(updated.clone());
380                self.runtime.set_auto_continue(false);
381                emit_goal_updated(&context, &updated);
382                Ok(make_result(
383                    &invocation.id,
384                    true,
385                    json!({
386                        "goal": {
387                            "goal_id": updated.goal_id.as_str(),
388                            "objective": updated.objective,
389                            "status": "blocked",
390                            "tokens_used": updated.tokens_used,
391                            "token_budget": updated.token_budget,
392                        },
393                        "message": "Goal marked as blocked."
394                    }),
395                ))
396            }
397            other => Ok(make_result(
398                &invocation.id,
399                false,
400                json!({
401                    "error": format!(
402                        "update_goal can only mark the existing goal complete or blocked; got `{other}`. Pause, resume, budget-limited, and usage-limited status changes are controlled by the user or system"
403                    )
404                }),
405            )),
406        }
407    }
408}
409
410// ── update_goal_checklist (host/API only; not exposed to the model) ──
411
412/// Host-facing checklist tool kept for SDK/server compatibility. Not registered
413/// on the model tool surface.
414pub struct UpdateGoalChecklistTool {
415    runtime: Arc<GoalRuntimeHandle>,
416}
417
418impl UpdateGoalChecklistTool {
419    pub fn new(runtime: Arc<GoalRuntimeHandle>) -> Self {
420        Self { runtime }
421    }
422
423    pub fn definition_static() -> ToolDefinition {
424        ToolDefinition {
425            name: "update_goal_checklist".to_string(),
426            description: "Host/API checklist management for a session goal (not model-facing)."
427                .to_string(),
428            kind: ToolKind::Write,
429            input_schema: json!({
430                "type": "object",
431                "properties": {
432                    "action": {
433                        "type": "string",
434                        "enum": ["set", "update"]
435                    },
436                    "tasks": {
437                        "type": "array",
438                        "items": { "type": "string" }
439                    },
440                    "task_id": { "type": "integer" },
441                    "status": {
442                        "type": "string",
443                        "enum": ["pending", "in_progress", "done", "verified", "skipped"]
444                    },
445                    "verification": { "type": "string" }
446                },
447                "required": ["action"],
448                "additionalProperties": false
449            }),
450            metadata: ToolMetadata {
451                tags: vec![
452                    "goal".to_string(),
453                    "session".to_string(),
454                    "host".to_string(),
455                ],
456                capabilities: vec!["goal.update".to_string()],
457                exposure: crate::tool::ToolExposure::Internal,
458                ..ToolMetadata::default()
459            },
460        }
461    }
462}
463
464#[async_trait]
465impl Tool for UpdateGoalChecklistTool {
466    fn definition(&self) -> ToolDefinition {
467        Self::definition_static()
468    }
469
470    async fn invoke(&self, invocation: ToolInvocation) -> anyhow::Result<ToolResult> {
471        self.invoke_with_context(invocation, ToolInvocationContext::default())
472            .await
473    }
474
475    async fn invoke_with_context(
476        &self,
477        invocation: ToolInvocation,
478        context: ToolInvocationContext,
479    ) -> anyhow::Result<ToolResult> {
480        use super::types::{GoalTask, TaskStatus};
481
482        let args = invocation.input.clone();
483        let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("");
484
485        let result = match action {
486            "set" => {
487                let tasks = args.get("tasks").and_then(|v| v.as_array()).cloned();
488                let Some(tasks) = tasks else {
489                    return Ok(make_result(
490                        &invocation.id,
491                        false,
492                        json!({"error": "`tasks` array is required for action `set`"}),
493                    ));
494                };
495                let goal_tasks: Vec<GoalTask> = tasks
496                    .iter()
497                    .enumerate()
498                    .filter_map(|(i, v)| {
499                        v.as_str()
500                            .map(|s| s.trim().to_string())
501                            .filter(|s| !s.is_empty())
502                            .map(|desc| GoalTask::new(i, desc))
503                    })
504                    .collect();
505                match self.runtime.update_checklist(goal_tasks.clone()) {
506                    Some(goal) => {
507                        emit_goal_updated(&context, &goal);
508                        make_result(
509                            &invocation.id,
510                            true,
511                            json!({
512                                "updated": true,
513                                "task_count": goal_tasks.len(),
514                                "message": "Checklist set."
515                            }),
516                        )
517                    }
518                    None => make_result(
519                        &invocation.id,
520                        false,
521                        json!({"error": "No goal is currently set."}),
522                    ),
523                }
524            }
525            "update" => {
526                let task_id = args
527                    .get("task_id")
528                    .and_then(|v| v.as_u64())
529                    .map(|v| v as usize);
530                let status_str = args.get("status").and_then(|v| v.as_str());
531                let (Some(task_id), Some(status_str)) = (task_id, status_str) else {
532                    return Ok(make_result(
533                        &invocation.id,
534                        false,
535                        json!({"error": "`task_id` and `status` are required for action `update`"}),
536                    ));
537                };
538                let status = match status_str {
539                    "pending" => TaskStatus::Pending,
540                    "in_progress" => TaskStatus::InProgress,
541                    "done" => TaskStatus::Done,
542                    "verified" => TaskStatus::Verified,
543                    "skipped" => TaskStatus::Skipped,
544                    other => {
545                        return Ok(make_result(
546                            &invocation.id,
547                            false,
548                            json!({"error": format!("Unknown status `{other}`")}),
549                        ));
550                    }
551                };
552                match self.runtime.update_task_status(task_id, status) {
553                    Some(mut goal) => {
554                        if let Some(verification) =
555                            args.get("verification").and_then(|v| v.as_str())
556                        {
557                            if let Some(task) = goal.checklist.get_mut(task_id) {
558                                task.verification = Some(verification.to_string());
559                                task.verified = status == TaskStatus::Verified;
560                            }
561                            self.runtime.update_goal(goal.clone());
562                        }
563                        emit_goal_updated(&context, &goal);
564                        make_result(
565                            &invocation.id,
566                            true,
567                            json!({
568                                "updated": true,
569                                "task_id": task_id,
570                                "status": status_str,
571                            }),
572                        )
573                    }
574                    None => make_result(
575                        &invocation.id,
576                        false,
577                        json!({"error": "No goal is set or task_id is out of range."}),
578                    ),
579                }
580            }
581            other => make_result(
582                &invocation.id,
583                false,
584                json!({"error": format!("Unknown action `{other}`. Valid actions: set, update.")}),
585            ),
586        };
587        Ok(result)
588    }
589}
590
591/// Model-facing goal tool definitions (get / create / update).
592pub fn goal_tool_definitions() -> Vec<ToolDefinition> {
593    vec![
594        GetGoalTool::definition_static(),
595        CreateGoalTool::definition_static(),
596        UpdateGoalTool::definition_static(),
597    ]
598}