Skip to main content

bamboo_tools/tools/
goal.rs

1//! `update_goal` — the agent's explicit completion signal for the goal loop.
2//!
3//! Mirrors OpenAI Codex's `update_goal` tool: while a session goal is active the
4//! runtime keeps re-injecting a rigorous completion-audit continuation prompt,
5//! and the *main agent itself* declares the objective finished (or genuinely
6//! blocked) by calling this tool. The tool is intentionally minimal — it only
7//! records the declared status. The engine's post-execution handler persists it
8//! into the durable goal state, and a side-channel Gold evaluator double-checks
9//! the claim at the terminal point before the run actually stops.
10//!
11//! The tool is only surfaced to the model when the goal loop is active (see
12//! `resolve_available_tool_schemas_for_session`), so it never appears in
13//! ordinary sessions.
14
15use async_trait::async_trait;
16use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
17use serde::Deserialize;
18use serde_json::json;
19
20/// Canonical tool name. Kept identical to Codex so prompts transfer cleanly.
21pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";
22
23#[derive(Debug, Deserialize)]
24struct UpdateGoalArgs {
25    status: String,
26}
27
28/// Record the agent's self-reported goal status (`complete` | `blocked`).
29pub struct UpdateGoalTool;
30
31impl UpdateGoalTool {
32    pub fn new() -> Self {
33        Self
34    }
35}
36
37impl Default for UpdateGoalTool {
38    fn default() -> Self {
39        Self::new()
40    }
41}
42
43/// Parse and normalize the `status` argument, returning the canonical lowercase
44/// form on success. Shared with the engine handler so validation stays in one
45/// place.
46pub fn parse_update_goal_status(arguments: &str) -> Result<String, String> {
47    let parsed: UpdateGoalArgs = serde_json::from_str(arguments)
48        .map_err(|e| format!("invalid update_goal arguments: {e}"))?;
49    let status = parsed.status.trim().to_ascii_lowercase();
50    match status.as_str() {
51        "complete" | "blocked" => Ok(status),
52        other => Err(format!(
53            "update_goal.status must be 'complete' or 'blocked', got '{other}'"
54        )),
55    }
56}
57
58#[async_trait]
59impl Tool for UpdateGoalTool {
60    fn name(&self) -> &str {
61        UPDATE_GOAL_TOOL_NAME
62    }
63
64    fn description(&self) -> &str {
65        "Update the active session goal. Use this ONLY to mark the goal `complete` or `blocked`.\n\
66         Set status to `complete` only when the objective has actually been achieved and no required work remains — not because the budget is nearly exhausted or because you are stopping. Completion is reverified against the current state before the run ends, so do not claim it on weak, indirect, or unverified evidence.\n\
67         Set status to `blocked` only when the same blocking condition has repeated for at least three consecutive goal turns (counting the original turn and any automatic continuations) and you genuinely cannot make progress without user input or an external-state change. Never use `blocked` merely because work is hard, slow, uncertain, or incomplete."
68    }
69
70    /// Treated as read-only for approval/scheduling purposes: it touches no
71    /// filesystem or external state. The durable goal-state mutation happens in
72    /// the engine post-execution handler (same pattern as `session_note`).
73    fn classify(&self, _args: &serde_json::Value) -> ToolClass {
74        ToolClass::READONLY_PARALLEL
75    }
76
77    fn parameters_schema(&self) -> serde_json::Value {
78        json!({
79            "type": "object",
80            "properties": {
81                "status": {
82                    "type": "string",
83                    "enum": ["complete", "blocked"],
84                    "description": "`complete` when the full objective is achieved and verified; `blocked` only after the strict blocked audit (same blocker ≥3 consecutive goal turns)."
85                }
86            },
87            "required": ["status"],
88            "additionalProperties": false
89        })
90    }
91
92    async fn invoke(
93        &self,
94        args: serde_json::Value,
95        _ctx: ToolCtx,
96    ) -> Result<ToolOutcome, ToolError> {
97        let arguments = args.to_string();
98        let status = parse_update_goal_status(&arguments).map_err(ToolError::InvalidArguments)?;
99
100        let message = match status.as_str() {
101            "complete" => {
102                "Recorded goal status: complete. Before the run ends the runtime will verify the \
103                 objective against the current state; if anything remains unproven you will be \
104                 asked to keep working."
105            }
106            // Only "complete" | "blocked" reach here (validated above).
107            _ => "Recorded goal status: blocked.",
108        };
109
110        Ok(ToolOutcome::Completed(ToolResult::text(true, message)))
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117    use serde_json::json;
118
119    #[tokio::test]
120    async fn complete_status_is_recorded() {
121        let tool = UpdateGoalTool::new();
122        let out = tool
123            .invoke(json!({"status": "complete"}), ToolCtx::none("t"))
124            .await
125            .expect("complete accepted");
126        let ToolOutcome::Completed(result) = out else {
127            panic!("expected Completed")
128        };
129        assert!(result.success);
130        assert!(result.result.to_lowercase().contains("complete"));
131    }
132
133    #[tokio::test]
134    async fn blocked_status_is_recorded() {
135        let tool = UpdateGoalTool::new();
136        let out = tool
137            .invoke(json!({"status": "BLOCKED"}), ToolCtx::none("t"))
138            .await
139            .expect("blocked accepted (case-insensitive)");
140        let ToolOutcome::Completed(result) = out else {
141            panic!("expected Completed")
142        };
143        assert!(result.success);
144        assert!(result.result.to_lowercase().contains("blocked"));
145    }
146
147    #[tokio::test]
148    async fn rejects_unknown_status() {
149        let tool = UpdateGoalTool::new();
150        let result = tool
151            .invoke(json!({"status": "paused"}), ToolCtx::none("t"))
152            .await;
153        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
154    }
155
156    #[tokio::test]
157    async fn rejects_missing_status() {
158        let tool = UpdateGoalTool::new();
159        assert!(tool
160            .invoke(json!({}), ToolCtx::none("t"))
161            .await
162            .is_err());
163    }
164
165    #[test]
166    fn parse_helper_normalizes_case() {
167        assert_eq!(
168            parse_update_goal_status(r#"{"status":"Complete"}"#).unwrap(),
169            "complete"
170        );
171        assert!(parse_update_goal_status(r#"{"status":"nope"}"#).is_err());
172    }
173}