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, ToolError, 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 mutability(&self) -> crate::ToolMutability {
74        crate::ToolMutability::ReadOnly
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 execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
93        let arguments = args.to_string();
94        let status = parse_update_goal_status(&arguments).map_err(ToolError::InvalidArguments)?;
95
96        let message = match status.as_str() {
97            "complete" => {
98                "Recorded goal status: complete. Before the run ends the runtime will verify the \
99                 objective against the current state; if anything remains unproven you will be \
100                 asked to keep working."
101            }
102            // Only "complete" | "blocked" reach here (validated above).
103            _ => "Recorded goal status: blocked.",
104        };
105
106        Ok(ToolResult::text(true, message))
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use serde_json::json;
114
115    #[tokio::test]
116    async fn complete_status_is_recorded() {
117        let tool = UpdateGoalTool::new();
118        let result = tool
119            .execute(json!({"status": "complete"}))
120            .await
121            .expect("complete accepted");
122        assert!(result.success);
123        assert!(result.result.to_lowercase().contains("complete"));
124    }
125
126    #[tokio::test]
127    async fn blocked_status_is_recorded() {
128        let tool = UpdateGoalTool::new();
129        let result = tool
130            .execute(json!({"status": "BLOCKED"}))
131            .await
132            .expect("blocked accepted (case-insensitive)");
133        assert!(result.success);
134        assert!(result.result.to_lowercase().contains("blocked"));
135    }
136
137    #[tokio::test]
138    async fn rejects_unknown_status() {
139        let tool = UpdateGoalTool::new();
140        let result = tool.execute(json!({"status": "paused"})).await;
141        assert!(matches!(result, Err(ToolError::InvalidArguments(_))));
142    }
143
144    #[tokio::test]
145    async fn rejects_missing_status() {
146        let tool = UpdateGoalTool::new();
147        assert!(tool.execute(json!({})).await.is_err());
148    }
149
150    #[test]
151    fn parse_helper_normalizes_case() {
152        assert_eq!(
153            parse_update_goal_status(r#"{"status":"Complete"}"#).unwrap(),
154            "complete"
155        );
156        assert!(parse_update_goal_status(r#"{"status":"nope"}"#).is_err());
157    }
158}