Skip to main content

bamboo_tools/tools/
conclusion_with_options.rs

1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolError, ToolResult};
3use serde::Deserialize;
4use serde_json::json;
5
6const DEFAULT_OPTIONS: [&str; 2] = ["OK", "Need changes"];
7const MAX_OPTIONS: usize = 6;
8const MAX_LIST_ITEMS: usize = 8;
9
10fn default_options() -> Vec<String> {
11    DEFAULT_OPTIONS.iter().map(|s| (*s).to_string()).collect()
12}
13
14fn default_allow_custom() -> bool {
15    true
16}
17
18fn normalize_text(value: &str) -> Option<String> {
19    let trimmed = value.trim();
20    if trimmed.is_empty() {
21        None
22    } else {
23        Some(trimmed.to_string())
24    }
25}
26
27fn normalize_optional_text(value: Option<String>) -> Option<String> {
28    value.and_then(|raw| normalize_text(&raw))
29}
30
31fn normalize_text_list(values: Vec<String>) -> Vec<String> {
32    values
33        .into_iter()
34        .filter_map(|value| normalize_text(&value))
35        .take(MAX_LIST_ITEMS)
36        .collect()
37}
38
39#[derive(Debug, Deserialize)]
40struct ConclusionWithOptionsMermaidArgs {
41    #[serde(default)]
42    title: Option<String>,
43    graph: String,
44}
45
46#[derive(Debug, Deserialize)]
47struct ConclusionWithOptionsConclusionArgs {
48    #[serde(default)]
49    title: Option<String>,
50    summary: String,
51    #[serde(default)]
52    key_points: Vec<String>,
53    #[serde(default)]
54    next_steps: Vec<String>,
55    #[serde(default)]
56    confidence: Option<String>,
57    mermaid: ConclusionWithOptionsMermaidArgs,
58}
59
60#[derive(Debug, Deserialize)]
61struct ConclusionWithOptionsArgs {
62    question: String,
63    #[serde(default)]
64    options: Vec<String>,
65    #[serde(default = "default_allow_custom")]
66    allow_custom: bool,
67    conclusion: ConclusionWithOptionsConclusionArgs,
68}
69
70/// Tool for asking user a question with multiple choice options
71pub struct ConclusionWithOptionsTool;
72
73impl ConclusionWithOptionsTool {
74    /// Create a new ConclusionWithOptionsTool instance.
75    ///
76    /// This tool prompts the user with a question and provides multiple choice options.
77    /// It supports custom answers when `allow_custom` is true.
78    pub fn new() -> Self {
79        Self
80    }
81}
82
83impl Default for ConclusionWithOptionsTool {
84    fn default() -> Self {
85        Self::new()
86    }
87}
88
89#[async_trait]
90impl Tool for ConclusionWithOptionsTool {
91    fn name(&self) -> &str {
92        "conclusion_with_options"
93    }
94
95    fn description(&self) -> &str {
96        "Ask the user a question with options and wait for the user to select or enter a custom answer. Use this as the final interaction step when wrapping up a task turn or when the user must choose next steps. The `conclusion` object is required and must include both a summary and a Mermaid graph."
97    }
98
99    fn parameters_schema(&self) -> serde_json::Value {
100        json!({
101            "type": "object",
102            "properties": {
103                "question": {
104                    "type": "string",
105                    "description": "The question to display to the user"
106                },
107                "conclusion": {
108                    "type": "object",
109                    "description": "Structured wrap-up context shown before the confirmation question.",
110                    "properties": {
111                        "title": {
112                            "type": "string",
113                            "description": "Optional title for the conclusion block."
114                        },
115                        "summary": {
116                            "type": "string",
117                            "description": "Main summary text shown to the user."
118                        },
119                        "key_points": {
120                            "type": "array",
121                            "description": "Optional short bullet points supporting the summary.",
122                            "items": { "type": "string" }
123                        },
124                        "next_steps": {
125                            "type": "array",
126                            "description": "Optional follow-up actions.",
127                            "items": { "type": "string" }
128                        },
129                        "confidence": {
130                            "type": "string",
131                            "description": "Optional confidence label, for example high/medium/low."
132                        },
133                        "mermaid": {
134                            "type": "object",
135                            "description": "Mermaid chart payload rendered in the UI.",
136                            "properties": {
137                                "title": {
138                                    "type": "string",
139                                    "description": "Optional Mermaid section title."
140                                },
141                                "graph": {
142                                    "type": "string",
143                                    "description": "Mermaid graph definition text."
144                                }
145                            },
146                            "required": ["graph"],
147                            "additionalProperties": false
148                        }
149                    },
150                    "required": ["summary", "mermaid"],
151                    "additionalProperties": false
152                },
153                "options": {
154                    "type": "array",
155                    "description": "Candidate answer options (optional). If omitted or invalid, defaults to [\"OK\", \"Need changes\"].",
156                    "items": {
157                        "type": "string"
158                    }
159                },
160                "allow_custom": {
161                    "type": "boolean",
162                    "description": "Whether to allow user to enter a custom answer (instead of selecting from options), default true",
163                    "default": true
164                }
165            },
166            "required": ["question", "conclusion"],
167            "additionalProperties": false
168        })
169    }
170
171    async fn execute(&self, args: serde_json::Value) -> Result<ToolResult, ToolError> {
172        let parsed: ConclusionWithOptionsArgs = serde_json::from_value(args).map_err(|error| {
173            ToolError::InvalidArguments(format!("Invalid conclusion_with_options args: {error}"))
174        })?;
175        let question = normalize_text(&parsed.question).ok_or_else(|| {
176            ToolError::InvalidArguments("question must be a non-empty string".to_string())
177        })?;
178        let summary = normalize_text(&parsed.conclusion.summary).ok_or_else(|| {
179            ToolError::InvalidArguments("conclusion.summary must be a non-empty string".to_string())
180        })?;
181        let mermaid_graph = normalize_text(&parsed.conclusion.mermaid.graph).ok_or_else(|| {
182            ToolError::InvalidArguments(
183                "conclusion.mermaid.graph must be a non-empty string".to_string(),
184            )
185        })?;
186
187        let mut options = normalize_text_list(parsed.options);
188
189        if options.len() < 2 {
190            options = default_options();
191        } else if options.len() > MAX_OPTIONS {
192            options.truncate(MAX_OPTIONS);
193        }
194
195        let allow_custom = parsed.allow_custom;
196
197        // Build the result payload that will be handled by the agent loop
198        let result_payload = json!({
199            "status": "awaiting_user_input",
200            "type": "conclusion_with_options",
201            "question": question,
202            "options": options,
203            "allow_custom": allow_custom,
204            "conclusion": {
205                "title": normalize_optional_text(parsed.conclusion.title).unwrap_or_else(|| "Conclusion".to_string()),
206                "summary": summary,
207                "key_points": normalize_text_list(parsed.conclusion.key_points),
208                "next_steps": normalize_text_list(parsed.conclusion.next_steps),
209                "confidence": normalize_optional_text(parsed.conclusion.confidence),
210                "mermaid": {
211                    "title": normalize_optional_text(parsed.conclusion.mermaid.title),
212                    "graph": mermaid_graph
213                }
214            }
215        });
216
217        Ok(ToolResult {
218            success: true,
219            result: result_payload.to_string(),
220            display_preference: Some("conclusion_with_options".to_string()),
221        })
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    fn minimal_conclusion() -> serde_json::Value {
230        json!({
231            "summary": "Core changes are done and ready for confirmation.",
232            "mermaid": {
233                "graph": "graph TD\nA[Done]-->B[Confirm]"
234            }
235        })
236    }
237
238    #[test]
239    fn test_conclusion_with_options_tool_name() {
240        let tool = ConclusionWithOptionsTool::new();
241        assert_eq!(tool.name(), "conclusion_with_options");
242    }
243
244    #[tokio::test]
245    async fn test_execute_valid_input() {
246        let tool = ConclusionWithOptionsTool::new();
247
248        let result = tool
249            .execute(json!({
250                "question": "Please select deployment environment",
251                "options": ["Development", "Testing", "Production"],
252                "conclusion": minimal_conclusion()
253            }))
254            .await
255            .expect("tool should execute successfully");
256
257        assert!(result.success);
258        assert_eq!(
259            result.display_preference,
260            Some("conclusion_with_options".to_string())
261        );
262
263        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
264        assert_eq!(parsed["status"], "awaiting_user_input");
265        assert_eq!(parsed["question"], "Please select deployment environment");
266        assert!(parsed["allow_custom"].as_bool().unwrap());
267        assert_eq!(
268            parsed["conclusion"]["summary"],
269            "Core changes are done and ready for confirmation."
270        );
271        assert_eq!(
272            parsed["conclusion"]["mermaid"]["graph"],
273            "graph TD\nA[Done]-->B[Confirm]"
274        );
275    }
276
277    #[tokio::test]
278    async fn test_execute_accepts_two_options() {
279        let tool = ConclusionWithOptionsTool::new();
280
281        let result = tool
282            .execute(json!({
283                "question": "Please confirm?",
284                "options": ["Yes", "No"],
285                "conclusion": minimal_conclusion()
286            }))
287            .await;
288
289        assert!(result.is_ok());
290    }
291
292    #[tokio::test]
293    async fn test_execute_with_too_few_options_uses_defaults() {
294        let tool = ConclusionWithOptionsTool::new();
295
296        let result = tool
297            .execute(json!({
298                "question": "Please select?",
299                "options": ["Only one option"],
300                "conclusion": minimal_conclusion()
301            }))
302            .await
303            .expect("tool should execute with fallback defaults");
304
305        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
306        assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
307    }
308
309    #[tokio::test]
310    async fn test_execute_without_options_uses_defaults() {
311        let tool = ConclusionWithOptionsTool::new();
312
313        let result = tool
314            .execute(json!({
315                "question": "Any other requests before I finish?",
316                "conclusion": minimal_conclusion()
317            }))
318            .await
319            .expect("tool should execute without options");
320
321        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
322        assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
323    }
324
325    #[tokio::test]
326    async fn test_execute_truncates_options_to_six_items() {
327        let tool = ConclusionWithOptionsTool::new();
328
329        let result = tool
330            .execute(json!({
331                "question": "Please pick one",
332                "options": ["1", "2", "3", "4", "5", "6", "7"],
333                "conclusion": minimal_conclusion()
334            }))
335            .await
336            .expect("tool should execute and truncate options");
337
338        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
339        assert_eq!(parsed["options"], json!(["1", "2", "3", "4", "5", "6"]));
340    }
341
342    #[tokio::test]
343    async fn test_execute_with_allow_custom_false() {
344        let tool = ConclusionWithOptionsTool::new();
345
346        let result = tool
347            .execute(json!({
348                "question": "Please confirm",
349                "options": ["Yes", "No", "Cancel"],
350                "allow_custom": false,
351                "conclusion": minimal_conclusion()
352            }))
353            .await
354            .expect("tool should execute");
355
356        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
357        assert!(!parsed["allow_custom"].as_bool().unwrap());
358    }
359
360    #[tokio::test]
361    async fn test_execute_rejects_missing_conclusion() {
362        let tool = ConclusionWithOptionsTool::new();
363
364        let result = tool
365            .execute(json!({
366                "question": "Please confirm"
367            }))
368            .await;
369
370        assert!(result.is_err());
371        let error = result.expect_err("expected invalid args");
372        if let ToolError::InvalidArguments(message) = error {
373            assert!(message.contains("conclusion"));
374        } else {
375            panic!("expected invalid arguments");
376        }
377    }
378
379    #[tokio::test]
380    async fn test_execute_rejects_empty_mermaid_graph() {
381        let tool = ConclusionWithOptionsTool::new();
382
383        let result = tool
384            .execute(json!({
385                "question": "Please confirm",
386                "conclusion": {
387                    "summary": "Summary",
388                    "mermaid": { "graph": "   " }
389                }
390            }))
391            .await;
392
393        assert!(result.is_err());
394        let error = result.expect_err("expected invalid args");
395        if let ToolError::InvalidArguments(message) = error {
396            assert!(message.contains("conclusion.mermaid.graph"));
397        } else {
398            panic!("expected invalid arguments");
399        }
400    }
401}