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            images: Vec::new(),
222        })
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    fn minimal_conclusion() -> serde_json::Value {
231        json!({
232            "summary": "Core changes are done and ready for confirmation.",
233            "mermaid": {
234                "graph": "graph TD\nA[Done]-->B[Confirm]"
235            }
236        })
237    }
238
239    #[test]
240    fn test_conclusion_with_options_tool_name() {
241        let tool = ConclusionWithOptionsTool::new();
242        assert_eq!(tool.name(), "conclusion_with_options");
243    }
244
245    #[tokio::test]
246    async fn test_execute_valid_input() {
247        let tool = ConclusionWithOptionsTool::new();
248
249        let result = tool
250            .execute(json!({
251                "question": "Please select deployment environment",
252                "options": ["Development", "Testing", "Production"],
253                "conclusion": minimal_conclusion()
254            }))
255            .await
256            .expect("tool should execute successfully");
257
258        assert!(result.success);
259        assert_eq!(
260            result.display_preference,
261            Some("conclusion_with_options".to_string())
262        );
263
264        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
265        assert_eq!(parsed["status"], "awaiting_user_input");
266        assert_eq!(parsed["question"], "Please select deployment environment");
267        assert!(parsed["allow_custom"].as_bool().unwrap());
268        assert_eq!(
269            parsed["conclusion"]["summary"],
270            "Core changes are done and ready for confirmation."
271        );
272        assert_eq!(
273            parsed["conclusion"]["mermaid"]["graph"],
274            "graph TD\nA[Done]-->B[Confirm]"
275        );
276    }
277
278    #[tokio::test]
279    async fn test_execute_accepts_two_options() {
280        let tool = ConclusionWithOptionsTool::new();
281
282        let result = tool
283            .execute(json!({
284                "question": "Please confirm?",
285                "options": ["Yes", "No"],
286                "conclusion": minimal_conclusion()
287            }))
288            .await;
289
290        assert!(result.is_ok());
291    }
292
293    #[tokio::test]
294    async fn test_execute_with_too_few_options_uses_defaults() {
295        let tool = ConclusionWithOptionsTool::new();
296
297        let result = tool
298            .execute(json!({
299                "question": "Please select?",
300                "options": ["Only one option"],
301                "conclusion": minimal_conclusion()
302            }))
303            .await
304            .expect("tool should execute with fallback defaults");
305
306        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
307        assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
308    }
309
310    #[tokio::test]
311    async fn test_execute_without_options_uses_defaults() {
312        let tool = ConclusionWithOptionsTool::new();
313
314        let result = tool
315            .execute(json!({
316                "question": "Any other requests before I finish?",
317                "conclusion": minimal_conclusion()
318            }))
319            .await
320            .expect("tool should execute without options");
321
322        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
323        assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
324    }
325
326    #[tokio::test]
327    async fn test_execute_truncates_options_to_six_items() {
328        let tool = ConclusionWithOptionsTool::new();
329
330        let result = tool
331            .execute(json!({
332                "question": "Please pick one",
333                "options": ["1", "2", "3", "4", "5", "6", "7"],
334                "conclusion": minimal_conclusion()
335            }))
336            .await
337            .expect("tool should execute and truncate options");
338
339        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
340        assert_eq!(parsed["options"], json!(["1", "2", "3", "4", "5", "6"]));
341    }
342
343    #[tokio::test]
344    async fn test_execute_with_allow_custom_false() {
345        let tool = ConclusionWithOptionsTool::new();
346
347        let result = tool
348            .execute(json!({
349                "question": "Please confirm",
350                "options": ["Yes", "No", "Cancel"],
351                "allow_custom": false,
352                "conclusion": minimal_conclusion()
353            }))
354            .await
355            .expect("tool should execute");
356
357        let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
358        assert!(!parsed["allow_custom"].as_bool().unwrap());
359    }
360
361    #[tokio::test]
362    async fn test_execute_rejects_missing_conclusion() {
363        let tool = ConclusionWithOptionsTool::new();
364
365        let result = tool
366            .execute(json!({
367                "question": "Please confirm"
368            }))
369            .await;
370
371        assert!(result.is_err());
372        let error = result.expect_err("expected invalid args");
373        if let ToolError::InvalidArguments(message) = error {
374            assert!(message.contains("conclusion"));
375        } else {
376            panic!("expected invalid arguments");
377        }
378    }
379
380    #[tokio::test]
381    async fn test_execute_rejects_empty_mermaid_graph() {
382        let tool = ConclusionWithOptionsTool::new();
383
384        let result = tool
385            .execute(json!({
386                "question": "Please confirm",
387                "conclusion": {
388                    "summary": "Summary",
389                    "mermaid": { "graph": "   " }
390                }
391            }))
392            .await;
393
394        assert!(result.is_err());
395        let error = result.expect_err("expected invalid args");
396        if let ToolError::InvalidArguments(message) = error {
397            assert!(message.contains("conclusion.mermaid.graph"));
398        } else {
399            panic!("expected invalid arguments");
400        }
401    }
402}