1use async_trait::async_trait;
2use bamboo_agent_core::{Tool, ToolCtx, ToolError, ToolOutcome, 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
70pub struct ConclusionWithOptionsTool;
72
73impl ConclusionWithOptionsTool {
74 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 invoke(
172 &self,
173 args: serde_json::Value,
174 ctx: ToolCtx,
175 ) -> Result<ToolOutcome, ToolError> {
176 let parsed: ConclusionWithOptionsArgs = serde_json::from_value(args).map_err(|error| {
177 ToolError::InvalidArguments(format!("Invalid conclusion_with_options args: {error}"))
178 })?;
179 let question = normalize_text(&parsed.question).ok_or_else(|| {
180 ToolError::InvalidArguments("question must be a non-empty string".to_string())
181 })?;
182 let summary = normalize_text(&parsed.conclusion.summary).ok_or_else(|| {
183 ToolError::InvalidArguments("conclusion.summary must be a non-empty string".to_string())
184 })?;
185 let mermaid_graph = normalize_text(&parsed.conclusion.mermaid.graph).ok_or_else(|| {
186 ToolError::InvalidArguments(
187 "conclusion.mermaid.graph must be a non-empty string".to_string(),
188 )
189 })?;
190
191 let mut options = normalize_text_list(parsed.options);
192
193 if options.len() < 2 {
194 options = default_options();
195 } else if options.len() > MAX_OPTIONS {
196 options.truncate(MAX_OPTIONS);
197 }
198
199 let allow_custom = parsed.allow_custom;
200
201 let pending_question = bamboo_agent_core::PendingQuestion {
205 tool_call_id: ctx.tool_call_id.to_string(),
206 tool_name: self.name().to_string(),
207 question: question.clone(),
208 options: options.clone(),
209 allow_custom,
210 source: bamboo_agent_core::PendingQuestionSource::PauseTool,
211 };
212
213 let result_payload = json!({
215 "status": "awaiting_user_input",
216 "type": "conclusion_with_options",
217 "question": question,
218 "options": options,
219 "allow_custom": allow_custom,
220 "conclusion": {
221 "title": normalize_optional_text(parsed.conclusion.title).unwrap_or_else(|| "Conclusion".to_string()),
222 "summary": summary,
223 "key_points": normalize_text_list(parsed.conclusion.key_points),
224 "next_steps": normalize_text_list(parsed.conclusion.next_steps),
225 "confidence": normalize_optional_text(parsed.conclusion.confidence),
226 "mermaid": {
227 "title": normalize_optional_text(parsed.conclusion.mermaid.title),
228 "graph": mermaid_graph
229 }
230 }
231 });
232
233 Ok(ToolOutcome::NeedsHuman {
234 question: pending_question,
235 result: ToolResult {
236 success: true,
237 result: result_payload.to_string(),
238 display_preference: Some("conclusion_with_options".to_string()),
239 images: Vec::new(),
240 },
241 })
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 fn minimal_conclusion() -> serde_json::Value {
250 json!({
251 "summary": "Core changes are done and ready for confirmation.",
252 "mermaid": {
253 "graph": "graph TD\nA[Done]-->B[Confirm]"
254 }
255 })
256 }
257
258 #[test]
259 fn test_conclusion_with_options_tool_name() {
260 let tool = ConclusionWithOptionsTool::new();
261 assert_eq!(tool.name(), "conclusion_with_options");
262 }
263
264 #[tokio::test]
265 async fn test_execute_valid_input() {
266 let tool = ConclusionWithOptionsTool::new();
267
268 let out = tool
269 .invoke(
270 json!({
271 "question": "Please select deployment environment",
272 "options": ["Development", "Testing", "Production"],
273 "conclusion": minimal_conclusion()
274 }),
275 ToolCtx::none("t"),
276 )
277 .await
278 .expect("tool should execute successfully");
279 let ToolOutcome::NeedsHuman { result, .. } = out else {
280 panic!("expected NeedsHuman")
281 };
282
283 assert!(result.success);
284 assert_eq!(
285 result.display_preference,
286 Some("conclusion_with_options".to_string())
287 );
288
289 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
290 assert_eq!(parsed["status"], "awaiting_user_input");
291 assert_eq!(parsed["question"], "Please select deployment environment");
292 assert!(parsed["allow_custom"].as_bool().unwrap());
293 assert_eq!(
294 parsed["conclusion"]["summary"],
295 "Core changes are done and ready for confirmation."
296 );
297 assert_eq!(
298 parsed["conclusion"]["mermaid"]["graph"],
299 "graph TD\nA[Done]-->B[Confirm]"
300 );
301 }
302
303 #[tokio::test]
304 async fn test_execute_accepts_two_options() {
305 let tool = ConclusionWithOptionsTool::new();
306
307 let result = tool
308 .invoke(
309 json!({
310 "question": "Please confirm?",
311 "options": ["Yes", "No"],
312 "conclusion": minimal_conclusion()
313 }),
314 ToolCtx::none("t"),
315 )
316 .await;
317
318 assert!(result.is_ok());
319 }
320
321 #[tokio::test]
322 async fn test_execute_with_too_few_options_uses_defaults() {
323 let tool = ConclusionWithOptionsTool::new();
324
325 let out = tool
326 .invoke(
327 json!({
328 "question": "Please select?",
329 "options": ["Only one option"],
330 "conclusion": minimal_conclusion()
331 }),
332 ToolCtx::none("t"),
333 )
334 .await
335 .expect("tool should execute with fallback defaults");
336 let ToolOutcome::NeedsHuman { result, .. } = out else {
337 panic!("expected NeedsHuman")
338 };
339
340 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
341 assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
342 }
343
344 #[tokio::test]
345 async fn test_execute_without_options_uses_defaults() {
346 let tool = ConclusionWithOptionsTool::new();
347
348 let out = tool
349 .invoke(
350 json!({
351 "question": "Any other requests before I finish?",
352 "conclusion": minimal_conclusion()
353 }),
354 ToolCtx::none("t"),
355 )
356 .await
357 .expect("tool should execute without options");
358 let ToolOutcome::NeedsHuman { result, .. } = out else {
359 panic!("expected NeedsHuman")
360 };
361
362 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
363 assert_eq!(parsed["options"], json!(["OK", "Need changes"]));
364 }
365
366 #[tokio::test]
367 async fn test_execute_truncates_options_to_six_items() {
368 let tool = ConclusionWithOptionsTool::new();
369
370 let out = tool
371 .invoke(
372 json!({
373 "question": "Please pick one",
374 "options": ["1", "2", "3", "4", "5", "6", "7"],
375 "conclusion": minimal_conclusion()
376 }),
377 ToolCtx::none("t"),
378 )
379 .await
380 .expect("tool should execute and truncate options");
381 let ToolOutcome::NeedsHuman { result, .. } = out else {
382 panic!("expected NeedsHuman")
383 };
384
385 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
386 assert_eq!(parsed["options"], json!(["1", "2", "3", "4", "5", "6"]));
387 }
388
389 #[tokio::test]
390 async fn test_execute_with_allow_custom_false() {
391 let tool = ConclusionWithOptionsTool::new();
392
393 let out = tool
394 .invoke(
395 json!({
396 "question": "Please confirm",
397 "options": ["Yes", "No", "Cancel"],
398 "allow_custom": false,
399 "conclusion": minimal_conclusion()
400 }),
401 ToolCtx::none("t"),
402 )
403 .await
404 .expect("tool should execute");
405 let ToolOutcome::NeedsHuman { result, .. } = out else {
406 panic!("expected NeedsHuman")
407 };
408
409 let parsed: serde_json::Value = serde_json::from_str(&result.result).unwrap();
410 assert!(!parsed["allow_custom"].as_bool().unwrap());
411 }
412
413 #[tokio::test]
414 async fn test_execute_rejects_missing_conclusion() {
415 let tool = ConclusionWithOptionsTool::new();
416
417 let result = tool
418 .invoke(
419 json!({
420 "question": "Please confirm"
421 }),
422 ToolCtx::none("t"),
423 )
424 .await;
425
426 assert!(result.is_err());
427 let error = result.expect_err("expected invalid args");
428 if let ToolError::InvalidArguments(message) = error {
429 assert!(message.contains("conclusion"));
430 } else {
431 panic!("expected invalid arguments");
432 }
433 }
434
435 #[tokio::test]
436 async fn test_execute_rejects_empty_mermaid_graph() {
437 let tool = ConclusionWithOptionsTool::new();
438
439 let result = tool
440 .invoke(
441 json!({
442 "question": "Please confirm",
443 "conclusion": {
444 "summary": "Summary",
445 "mermaid": { "graph": " " }
446 }
447 }),
448 ToolCtx::none("t"),
449 )
450 .await;
451
452 assert!(result.is_err());
453 let error = result.expect_err("expected invalid args");
454 if let ToolError::InvalidArguments(message) = error {
455 assert!(message.contains("conclusion.mermaid.graph"));
456 } else {
457 panic!("expected invalid arguments");
458 }
459 }
460}