1use std::collections::HashMap;
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16use async_trait::async_trait;
17
18use mermaid_model::question::{
19 OptionPreview, Question, QuestionAnswer, QuestionKind, QuestionOption, QuestionResolution,
20 TextValidate,
21};
22
23use mermaid_domain::{ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata};
24
25use super::super::ctx::ExecContext;
26use super::ToolExecutor;
27
28pub struct AskUserQuestionTool;
29
30fn format_answers(answers: &[QuestionAnswer]) -> String {
33 let mut out = String::from("The user answered your question(s):\n");
34 for a in answers {
35 let value = if a.selected.is_empty() {
36 "(no selection)".to_string()
37 } else {
38 a.selected.join(", ")
39 };
40 out.push_str(&format!("- {} -> {}\n", a.question, value));
41 if let Some(note) = &a.note {
42 out.push_str(&format!(" (note: {note})\n"));
43 }
44 }
45 out
46}
47
48fn answers_metadata(answers: Vec<QuestionAnswer>, remembered: bool) -> ToolRunMetadata {
52 ToolRunMetadata {
53 detail: ToolMetadata::Questions {
54 answers,
55 remembered,
56 },
57 ..ToolRunMetadata::default()
58 }
59}
60
61fn summarize_answers(answers: &[QuestionAnswer]) -> String {
63 match answers {
64 [] => "no questions".to_string(),
65 [one] => {
66 if one.selected.is_empty() {
67 "no selection".to_string()
68 } else {
69 one.selected.join(", ")
70 }
71 },
72 many => format!("{} questions answered", many.len()),
73 }
74}
75
76fn parse_option(v: &serde_json::Value) -> Option<QuestionOption> {
78 let label = v.get("label").and_then(|x| x.as_str())?.to_string();
79 let description = v
80 .get("description")
81 .and_then(|x| x.as_str())
82 .map(str::to_string);
83 let preview = v.get("preview").and_then(parse_preview);
84 let recommended = label.to_lowercase().contains("(recommended)");
86 Some(QuestionOption {
87 label,
88 description,
89 recommended,
90 preview,
91 })
92}
93
94fn parse_preview(v: &serde_json::Value) -> Option<OptionPreview> {
95 let content = v.get("content").and_then(|x| x.as_str())?.to_string();
96 let language = v
97 .get("language")
98 .and_then(|x| x.as_str())
99 .map(str::to_string);
100 let diff = v.get("diff").and_then(|x| x.as_bool()).unwrap_or(false);
101 Some(OptionPreview {
102 content,
103 language,
104 diff,
105 })
106}
107
108fn parse_validate(v: Option<&serde_json::Value>) -> TextValidate {
111 match v {
112 None => TextValidate::Any,
113 Some(val) => {
114 if let Some(s) = val.as_str() {
115 match s {
116 "number" => TextValidate::Number,
117 "any" | "" => TextValidate::Any,
118 pat => TextValidate::Regex(pat.to_string()),
119 }
120 } else if let Some(pat) = val.get("regex").and_then(|x| x.as_str()) {
121 TextValidate::Regex(pat.to_string())
122 } else {
123 TextValidate::Any
124 }
125 },
126 }
127}
128
129fn parse_question(v: &serde_json::Value) -> Result<Question, String> {
131 let header = v
132 .get("header")
133 .and_then(|x| x.as_str())
134 .unwrap_or("")
135 .to_string();
136 let question = v
137 .get("question")
138 .and_then(|x| x.as_str())
139 .ok_or("each question needs a `question` string")?
140 .to_string();
141 let kind = match v.get("kind").and_then(|x| x.as_str()).unwrap_or("select") {
142 "select" => QuestionKind::Select,
143 "multiSelect" | "multiselect" => QuestionKind::MultiSelect,
144 "rank" => QuestionKind::Rank,
145 "text" => QuestionKind::Text {
146 validate: parse_validate(v.get("validate")),
147 },
148 "number" => QuestionKind::Number {
149 min: v.get("min").and_then(|x| x.as_f64()),
150 max: v.get("max").and_then(|x| x.as_f64()),
151 step: v.get("step").and_then(|x| x.as_f64()),
152 slider: v.get("slider").and_then(|x| x.as_bool()).unwrap_or(false),
153 },
154 "date" => QuestionKind::Date,
155 "path" => QuestionKind::Path {
156 must_exist: v
157 .get("mustExist")
158 .and_then(|x| x.as_bool())
159 .unwrap_or(false),
160 },
161 other => return Err(format!("unknown question kind: {other}")),
162 };
163 let options = v
164 .get("options")
165 .and_then(|x| x.as_array())
166 .map(|arr| arr.iter().filter_map(parse_option).collect::<Vec<_>>())
167 .unwrap_or_default();
168 let memory_key = v
169 .get("memoryKey")
170 .and_then(|x| x.as_str())
171 .filter(|s| !s.is_empty())
172 .map(str::to_string);
173 let q = Question {
174 header,
175 question,
176 kind,
177 options,
178 memory_key,
179 };
180 if q.is_choice() && q.options.is_empty() {
181 return Err(format!(
182 "question \"{}\" is a choice kind but has no options",
183 q.question
184 ));
185 }
186 Ok(q)
187}
188
189#[derive(serde::Serialize, serde::Deserialize, Clone)]
192struct StoredAnswer {
193 selected: Vec<String>,
194 #[serde(default)]
195 note: Option<String>,
196}
197
198fn prefs_path() -> Option<PathBuf> {
199 crate::app::get_config_dir()
200 .ok()
201 .map(|d| d.join("question_prefs.json"))
202}
203
204fn load_prefs_at(path: &Path) -> HashMap<String, StoredAnswer> {
205 std::fs::read_to_string(path)
206 .ok()
207 .and_then(|s| serde_json::from_str(&s).ok())
208 .unwrap_or_default()
209}
210
211fn save_prefs_at(path: &Path, map: &HashMap<String, StoredAnswer>) {
212 let json = match serde_json::to_string_pretty(map) {
213 Ok(json) => json,
214 Err(err) => {
215 tracing::warn!(error = %err, "ask_user_question: failed to serialize answer prefs");
216 return;
217 },
218 };
219 if let Err(err) = mermaid_runtime::write_atomic(path, json.as_bytes()) {
222 tracing::warn!(
223 error = %err,
224 path = %path.display(),
225 "ask_user_question: failed to persist answer prefs"
226 );
227 }
228}
229
230fn load_prefs() -> HashMap<String, StoredAnswer> {
231 prefs_path().map(|p| load_prefs_at(&p)).unwrap_or_default()
232}
233
234fn save_prefs(map: &HashMap<String, StoredAnswer>) {
235 if let Some(p) = prefs_path() {
236 save_prefs_at(&p, map);
237 }
238}
239
240fn remembered_answers(
249 questions: &[Question],
250 prefs: &HashMap<String, StoredAnswer>,
251) -> Option<Vec<QuestionAnswer>> {
252 questions
253 .iter()
254 .map(|q| {
255 let stored = prefs.get(q.memory_key.as_ref()?)?;
256 Some(QuestionAnswer {
257 header: q.header.clone(),
258 question: q.question.clone(),
259 selected: stored.selected.clone(),
260 note: stored.note.clone(),
261 })
262 })
263 .collect()
264}
265
266#[async_trait]
267impl ToolExecutor for AskUserQuestionTool {
268 fn name(&self) -> &'static str {
269 "ask_user_question"
270 }
271
272 fn schema(&self) -> ToolDefinition {
273 ToolDefinition {
274 name: "ask_user_question".to_string(),
275 description: "Ask the user one or more multiple-choice questions when you are genuinely blocked on a decision that is theirs to make — one you cannot resolve from their request, the code, or a sensible default. The terminal renders an interactive selectable prompt and the user's answer comes back as this tool's result. \
276 Use it only when the answer changes what you do next; do NOT use it for choices with an obvious default (just pick, say so, and proceed) or for facts you can verify yourself. The user can always type a custom \"Other\" answer, so your options need not be exhaustive. \
277 Batch up to 4 independent questions in one call rather than asking one at a time. Set each question's `kind`: `select` (pick one), `multiSelect` (pick any), `rank` (reorder options), or an input kind that collects a typed value — `text` (optional `validate`), `number` (`min`/`max`/`step`/`slider`), `date`, or `path`. Choice kinds need `options`; list a recommended option first and mark it by ending its label with \"(Recommended)\". \
278 Attach an optional preview to an option (a `content` string plus an optional `diff` flag) to show an ASCII mockup, code, config, or a unified diff side-by-side when that option is focused — a diff of the change an option would make is often clearer than a text description. \
279 Set `memoryKey` on a question to let the user remember the answer across sessions, so settled preferences (package manager, code style) aren't re-asked."
280 .to_string(),
281 input_schema: serde_json::json!({
282 "type": "object",
283 "properties": {
284 "questions": {
285 "type": "array",
286 "description": "1-4 questions to ask, shown together.",
287 "items": {
288 "type": "object",
289 "properties": {
290 "header": {
291 "type": "string",
292 "description": "Very short label (<=12 chars) shown as a chip, e.g. \"Database\"."
293 },
294 "question": {
295 "type": "string",
296 "description": "The full question text. Clear, specific, ends with a question mark."
297 },
298 "kind": {
299 "type": "string",
300 "enum": ["select", "multiSelect", "rank", "text", "number", "date", "path"],
301 "description": "How the question is answered. Choice kinds use `options`: `select` (pick one), `multiSelect` (pick any), `rank` (reorder). Input kinds collect a typed value: `text` (optional `validate`), `number` (optional `min`/`max`/`step`/`slider`), `date` (YYYY-MM-DD), `path`."
302 },
303 "validate": {
304 "type": "string",
305 "description": "For `text`: \"number\" to require a number, or a regex pattern the answer must match."
306 },
307 "min": { "type": "number", "description": "For `number`: minimum value." },
308 "max": { "type": "number", "description": "For `number`: maximum value." },
309 "step": { "type": "number", "description": "For `number`: increment for Up/Down." },
310 "slider": { "type": "boolean", "description": "For `number`: show a slider bar (needs `min` and `max`)." },
311 "mustExist": { "type": "boolean", "description": "For `path`: hint that the path should already exist." },
312 "memoryKey": { "type": "string", "description": "Stable key to remember this answer across sessions. If the user opts in, a later question with the same key auto-answers without prompting. Use for settled preferences (e.g. package manager, code style)." },
313 "options": {
314 "type": "array",
315 "description": "Options for choice kinds (select/multiSelect/rank); omit for input kinds. Two or more; long lists scroll.",
316 "items": {
317 "type": "object",
318 "properties": {
319 "label": {
320 "type": "string",
321 "description": "Concise choice text. End with \"(Recommended)\" to flag your suggestion."
322 },
323 "description": {
324 "type": "string",
325 "description": "One line explaining the option or its trade-off."
326 },
327 "preview": {
328 "type": "object",
329 "description": "Optional side-by-side preview shown when this option is focused.",
330 "properties": {
331 "content": { "type": "string", "description": "The preview body, shown as monospace lines." },
332 "language": { "type": "string", "description": "Language hint for the content (e.g. \"rust\", \"yaml\")." },
333 "diff": { "type": "boolean", "description": "Render content as a unified diff (+ lines green, - lines red). Best for showing the change an option would produce." }
334 },
335 "required": ["content"]
336 }
337 },
338 "required": ["label", "description"]
339 }
340 }
341 },
342 "required": ["question", "header", "kind"]
343 }
344 }
345 },
346 "required": ["questions"]
347 }),
348 }
349 }
350
351 async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
352 let start = Instant::now();
353 let secs = || start.elapsed().as_secs_f64();
354
355 let Some(questions_val) = args.get("questions").and_then(|v| v.as_array()) else {
356 return ToolOutcome::error("ask_user_question requires a `questions` array", secs());
357 };
358 if questions_val.is_empty() {
359 return ToolOutcome::error("`questions` must contain at least one question", secs());
360 }
361 let mut questions = Vec::with_capacity(questions_val.len());
362 for qv in questions_val {
363 match parse_question(qv) {
364 Ok(q) => questions.push(q),
365 Err(e) => return ToolOutcome::error(format!("invalid question: {e}"), secs()),
366 }
367 }
368
369 let prefs = load_prefs();
373 if let Some(answers) = remembered_answers(&questions, &prefs) {
374 return ToolOutcome::success(
375 format_answers(&answers),
376 format!("{} (remembered)", summarize_answers(&answers)),
377 secs(),
378 )
379 .with_metadata(answers_metadata(answers, true));
380 }
381
382 let Some(broker) = ctx.questions.as_ref() else {
383 return ToolOutcome::success(
386 "No interactive terminal is available, so the user could not be asked. \
387 Proceed using your best judgment and state the assumption you made.",
388 "no interactive terminal; proceeding without answers",
389 secs(),
390 );
391 };
392
393 match broker
394 .request(&ctx.token, ctx.turn, ctx.call_id, questions.clone())
395 .await
396 {
397 QuestionResolution::Answered { answers, remember } => {
398 if remember {
399 let mut prefs = prefs;
400 for (q, a) in questions.iter().zip(&answers) {
401 if let Some(key) = &q.memory_key
402 && !a.selected.is_empty()
403 {
404 prefs.insert(
405 key.clone(),
406 StoredAnswer {
407 selected: a.selected.clone(),
408 note: a.note.clone(),
409 },
410 );
411 }
412 }
413 save_prefs(&prefs);
414 }
415 ToolOutcome::success(
416 format_answers(&answers),
417 summarize_answers(&answers),
418 secs(),
419 )
420 .with_metadata(answers_metadata(answers, false))
421 },
422 QuestionResolution::Dismissed => ToolOutcome::success(
423 "The user dismissed the question(s) without answering. Do not re-ask unless you \
424 still need the information; otherwise proceed with your best judgment.",
425 "dismissed without answering",
426 secs(),
427 ),
428 QuestionResolution::Reformulate => ToolOutcome::success(
429 "The user chose to discuss these questions rather than answer them as posed. \
430 Do not re-issue the same questions; engage with what they say next and \
431 reformulate your approach based on their input.",
432 "user chose to chat about this instead",
433 secs(),
434 ),
435 }
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use mermaid_domain::QuestionAnswer;
443
444 #[test]
445 fn formats_keyed_answers() {
446 let answers = vec![
447 QuestionAnswer {
448 header: "Database".to_string(),
449 question: "Which database?".to_string(),
450 selected: vec!["PostgreSQL".to_string()],
451 note: None,
452 },
453 QuestionAnswer {
454 header: "Features".to_string(),
455 question: "Which features?".to_string(),
456 selected: vec!["Auth".to_string(), "Admin".to_string()],
457 note: Some("also add profiles".to_string()),
458 },
459 ];
460 let out = format_answers(&answers);
461 assert!(out.contains("Which database? -> PostgreSQL"));
462 assert!(out.contains("Which features? -> Auth, Admin"));
463 assert!(out.contains("(note: also add profiles)"));
464 }
465
466 #[test]
467 fn empty_selection_reads_as_no_selection() {
468 let answers = vec![QuestionAnswer {
469 header: "Layout".to_string(),
470 question: "Which layout?".to_string(),
471 selected: vec![],
472 note: None,
473 }];
474 assert!(format_answers(&answers).contains("-> (no selection)"));
475 assert_eq!(summarize_answers(&answers), "no selection");
476 }
477
478 #[tokio::test]
479 async fn headless_proceeds_without_broker() {
480 use mermaid_domain::{ToolCallId, TurnId};
481 let (ctx, _rx) = crate::providers::ctx::test_exec_context(
482 TurnId(1),
483 ToolCallId(1),
484 std::env::temp_dir(),
485 );
486 let out = AskUserQuestionTool
488 .execute(
489 serde_json::json!({
490 "questions": [{
491 "header": "DB",
492 "question": "Which database?",
493 "multiSelect": false,
494 "options": [
495 {"label": "PostgreSQL", "description": "relational"},
496 {"label": "SQLite", "description": "embedded"}
497 ]
498 }]
499 }),
500 ctx,
501 )
502 .await;
503 assert_eq!(out.status, mermaid_domain::ToolStatus::Success);
504 assert!(out.model_content.contains("Proceed"));
505 }
506
507 #[test]
508 fn prefs_round_trip() {
509 let dir = std::env::temp_dir().join(format!("mermaid_qprefs_{}", std::process::id()));
510 std::fs::create_dir_all(&dir).unwrap();
511 let path = dir.join("prefs.json");
512 let mut map = HashMap::new();
513 map.insert(
514 "pkg_mgr".to_string(),
515 StoredAnswer {
516 selected: vec!["pnpm".to_string()],
517 note: None,
518 },
519 );
520 save_prefs_at(&path, &map);
521 let loaded = load_prefs_at(&path);
522 assert_eq!(
523 loaded.get("pkg_mgr").map(|s| s.selected.clone()),
524 Some(vec!["pnpm".to_string()])
525 );
526 let _ = std::fs::remove_dir_all(&dir);
527 }
528
529 fn keyed(header: &str, memory_key: Option<&str>) -> Question {
530 Question {
531 header: header.to_string(),
532 question: format!("Which {header}?"),
533 kind: QuestionKind::Select,
534 options: Vec::new(),
535 memory_key: memory_key.map(str::to_string),
536 }
537 }
538
539 fn stored(selected: &str) -> StoredAnswer {
540 StoredAnswer {
541 selected: vec![selected.to_string()],
542 note: None,
543 }
544 }
545
546 #[test]
547 fn every_question_remembered_answers_without_asking() {
548 let questions = vec![keyed("Database", Some("db")), keyed("Runtime", Some("rt"))];
549 let prefs = HashMap::from([
550 ("db".to_string(), stored("PostgreSQL")),
551 ("rt".to_string(), stored("tokio")),
552 ]);
553
554 let answers = remembered_answers(&questions, &prefs).expect("both keys are remembered");
555
556 assert_eq!(answers.len(), 2);
557 assert_eq!(answers[0].header, "Database");
558 assert_eq!(answers[0].selected, vec!["PostgreSQL".to_string()]);
559 assert_eq!(answers[1].selected, vec!["tokio".to_string()]);
560 }
561
562 #[test]
563 fn one_unremembered_question_asks_the_whole_set() {
564 let questions = vec![keyed("Database", Some("db")), keyed("Runtime", Some("rt"))];
565 let prefs = HashMap::from([("db".to_string(), stored("PostgreSQL"))]);
566
567 assert!(remembered_answers(&questions, &prefs).is_none());
570 }
571
572 #[test]
576 fn a_question_without_a_memory_key_asks_rather_than_panicking() {
577 let questions = vec![keyed("Database", Some("db")), keyed("Runtime", None)];
578 let prefs = HashMap::from([("db".to_string(), stored("PostgreSQL"))]);
579
580 assert!(remembered_answers(&questions, &prefs).is_none());
581 }
582
583 #[test]
584 fn an_empty_question_set_is_vacuously_remembered() {
585 assert_eq!(
588 remembered_answers(&[], &HashMap::new()).map(|a| a.len()),
589 Some(0)
590 );
591 }
592}