Skip to main content

defect_agent/tool/
goal_done.rs

1//! Tool used by the AI to declare that a goal has been reached (for the `--goal`
2//! goal-driven loop).
3//!
4//! In `--goal` mode, the agent runs autonomously for multiple turns until the goal is
5//! reached. When the model decides the goal is achieved, it calls this tool, which marks
6//! [`crate::session::GoalState`] as `reached`. On a later turn where the model stops
7//! calling tools and voluntarily halts, the `goal-gate` hook
8//! ([`crate::hooks::builtin::GoalGate`]) reads the state in `before_turn_end`: if
9//! `reached`, it allows the loop to end; otherwise, it injects a "continue working"
10//! message to keep the agent going.
11//!
12//! Design note: the turn that calls this tool **does carry tool_use**, so the turn does
13//! not end immediately (see the turn loop in `session/turn.rs`). The model typically
14//! calls `goal_done`, confirms there is nothing more to do, and then stops naturally on
15//! the next turn, at which point `goal-gate` allows the loop to exit.
16//!
17//! `safety_hint = ReadOnly`: only writes an in-memory flag; does not touch files,
18//! network, or subprocesses.
19
20use std::pin::Pin;
21
22use agent_client_protocol_schema::{
23    Content, ContentBlock, TextContent, ToolCallContent, ToolCallUpdateFields, ToolKind,
24};
25use futures::future::BoxFuture;
26use serde::Deserialize;
27use serde_json::json;
28
29use crate::tool::{
30    SafetyClass, Tool, ToolCallDescription, ToolContext, ToolEvent, ToolSchema, ToolStream,
31};
32
33/// The name of the `goal_done` tool.
34pub const GOAL_DONE_TOOL_NAME: &str = "goal_done";
35
36/// The `goal_done` tool. Registered during `--goal` assembly; stateless (the flag lives
37/// on the shared `GoalState` in [`ToolContext::goal`], this tool only calls
38/// `mark_reached`).
39pub struct GoalDoneTool {
40    schema: ToolSchema,
41}
42
43impl Default for GoalDoneTool {
44    fn default() -> Self {
45        Self::new()
46    }
47}
48
49impl GoalDoneTool {
50    #[must_use]
51    pub fn new() -> Self {
52        Self {
53            schema: ToolSchema {
54                name: GOAL_DONE_TOOL_NAME.to_string(),
55                description: "Signal that the assigned goal has been fully achieved. Call this \
56                              only when you are confident the goal is genuinely complete and there \
57                              is nothing left to do. After calling it, stop taking further actions \
58                              so the run can finish."
59                    .to_string(),
60                input_schema: json!({
61                    "type": "object",
62                    "properties": {
63                        "summary": {
64                            "type": "string",
65                            "description": "Brief summary of how the goal was achieved (what was done, key results)."
66                        }
67                    },
68                    "required": []
69                }),
70            },
71        }
72    }
73}
74
75#[derive(Debug, Default, Deserialize)]
76struct GoalDoneArgs {
77    #[serde(default)]
78    summary: Option<String>,
79}
80
81impl Tool for GoalDoneTool {
82    fn schema(&self) -> &ToolSchema {
83        &self.schema
84    }
85
86    fn safety_hint(&self, _args: &serde_json::Value) -> SafetyClass {
87        SafetyClass::ReadOnly
88    }
89
90    fn describe<'a>(
91        &'a self,
92        _args: &'a serde_json::Value,
93        _ctx: ToolContext<'a>,
94    ) -> BoxFuture<'a, ToolCallDescription> {
95        Box::pin(async move {
96            let mut fields = ToolCallUpdateFields::default();
97            fields.title = Some("Mark goal as complete".to_string());
98            fields.kind = Some(ToolKind::Think);
99            ToolCallDescription { fields }
100        })
101    }
102
103    fn execute(&self, args: serde_json::Value, ctx: ToolContext<'_>) -> ToolStream {
104        // Set the flag: even in non-goal mode (goal=None), do not error — the tool should
105        // not be registered, but if it is somehow invoked, silently treat it as a no-op
106        // to avoid crashing the turn.
107        let goal = ctx.goal.clone();
108        let parsed: GoalDoneArgs = serde_json::from_value(args).unwrap_or_default();
109        let fut = async move {
110            if let Some(goal) = goal {
111                goal.mark_reached();
112            }
113            let text = match parsed.summary {
114                Some(s) if !s.is_empty() => {
115                    format!(
116                        "Goal marked as complete. The run will finish once you stop. Summary: {s}"
117                    )
118                }
119                _ => "Goal marked as complete. The run will finish once you stop.".to_string(),
120            };
121            let mut fields = ToolCallUpdateFields::default();
122            fields.content = Some(vec![ToolCallContent::Content(Content::new(
123                ContentBlock::Text(TextContent::new(text.clone())),
124            ))]);
125            fields.raw_output = Some(serde_json::Value::String(text));
126            ToolEvent::Completed(fields)
127        };
128        let s: Pin<Box<dyn futures::Stream<Item = ToolEvent> + Send>> =
129            Box::pin(futures::stream::once(fut));
130        s
131    }
132}