bamboo_tools/tools/
goal.rs1use async_trait::async_trait;
16use bamboo_agent_core::{Tool, ToolClass, ToolCtx, ToolError, ToolOutcome, ToolResult};
17use serde::Deserialize;
18use serde_json::json;
19
20pub const UPDATE_GOAL_TOOL_NAME: &str = "update_goal";
22
23#[derive(Debug, Deserialize)]
24struct UpdateGoalArgs {
25 status: String,
26}
27
28pub 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
43pub 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 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 _ => "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}