Skip to main content

mermaid_cli/providers/tool/
ask_user_question.rs

1//! The `ask_user_question` tool — the model's structured path to ask the user
2//! a decision it genuinely cannot make on its own.
3//!
4//! The model supplies 1–4 questions, each with a short header chip and a set of
5//! labeled options (single- or multi-select). The tool parks on the
6//! `QuestionBroker` (interactive runs) while the TUI renders a selectable modal,
7//! then formats the user's answers back into the tool result. Headless runs have
8//! no human to ask, so the tool returns a proceed-with-best-judgment result
9//! rather than blocking. Asking mutates nothing, so the tool is ungated (it
10//! never touches the policy gate) and runs in every safety mode.
11
12use 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
30/// Format the resolved answers into the text the model sees, keyed by question
31/// so a batched call never scrambles which answer belongs to which question.
32fn 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
48/// Ride the structured answers on the outcome metadata so the transcript can
49/// render each question → answer pair (`ToolMetadata::Questions`) instead of
50/// a bare duration line.
51fn 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
61/// One-line UI summary of the answers.
62fn 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
76/// Parse the model's flat option JSON into a `QuestionOption`.
77fn 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    // Honor the Claude-Code convention: a trailing "(Recommended)" flags it.
85    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
108/// Parse a `text` question's `validate`: the string "number"/"any", any other
109/// string as a regex pattern, or `{ "regex": "..." }`.
110fn 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
129/// Parse one flat question object into a `Question`.
130fn 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/// A remembered answer, persisted across sessions keyed by a question's
190/// `memory_key`.
191#[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    // Atomic write so a crash mid-save can't truncate the prefs file, and log on
220    // failure instead of silently losing the user's "remember this answer" choice.
221    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
240/// The remembered answer for every question, or `None` if any one of them is
241/// still unsettled.
242///
243/// One pass, rather than an `all(...)` check followed by a lookup that trusts
244/// it: the lookup that decides whether a question is already answered is the
245/// same lookup that builds its answer, so the two cannot disagree and no arm
246/// can panic. All-or-nothing is the point — a partially remembered set still
247/// has to be asked.
248fn 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(
357                "ask_user_question requires a `questions` array",
358                Some(secs()),
359            );
360        };
361        if questions_val.is_empty() {
362            return ToolOutcome::error(
363                "`questions` must contain at least one question",
364                Some(secs()),
365            );
366        }
367        let mut questions = Vec::with_capacity(questions_val.len());
368        for qv in questions_val {
369            match parse_question(qv) {
370                Ok(q) => questions.push(q),
371                Err(e) => {
372                    return ToolOutcome::error(format!("invalid question: {e}"), Some(secs()));
373                },
374            }
375        }
376
377        // Cross-session preferences: if every question already has a remembered
378        // answer, return them without prompting (works interactively and
379        // headlessly) so settled decisions aren't re-asked every session.
380        let prefs = load_prefs();
381        if let Some(answers) = remembered_answers(&questions, &prefs) {
382            return ToolOutcome::success(
383                format_answers(&answers),
384                format!("{} (remembered)", summarize_answers(&answers)),
385                secs(),
386            )
387            .with_metadata(answers_metadata(answers, true));
388        }
389
390        let Some(broker) = ctx.questions.as_ref() else {
391            // Headless / no interactive terminal: nobody to ask. Proceed rather
392            // than block an automated run.
393            return ToolOutcome::success(
394                "No interactive terminal is available, so the user could not be asked. \
395                 Proceed using your best judgment and state the assumption you made.",
396                "no interactive terminal; proceeding without answers",
397                secs(),
398            );
399        };
400
401        match broker
402            .request(&ctx.token, ctx.turn, ctx.call_id, questions.clone())
403            .await
404        {
405            QuestionResolution::Answered { answers, remember } => {
406                if remember {
407                    let mut prefs = prefs;
408                    for (q, a) in questions.iter().zip(&answers) {
409                        if let Some(key) = &q.memory_key
410                            && !a.selected.is_empty()
411                        {
412                            prefs.insert(
413                                key.clone(),
414                                StoredAnswer {
415                                    selected: a.selected.clone(),
416                                    note: a.note.clone(),
417                                },
418                            );
419                        }
420                    }
421                    save_prefs(&prefs);
422                }
423                ToolOutcome::success(
424                    format_answers(&answers),
425                    summarize_answers(&answers),
426                    secs(),
427                )
428                .with_metadata(answers_metadata(answers, false))
429            },
430            QuestionResolution::Dismissed => ToolOutcome::success(
431                "The user dismissed the question(s) without answering. Do not re-ask unless you \
432                 still need the information; otherwise proceed with your best judgment.",
433                "dismissed without answering",
434                secs(),
435            ),
436            QuestionResolution::Reformulate => ToolOutcome::success(
437                "The user chose to discuss these questions rather than answer them as posed. \
438                 Do not re-issue the same questions; engage with what they say next and \
439                 reformulate your approach based on their input.",
440                "user chose to chat about this instead",
441                secs(),
442            ),
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use mermaid_domain::QuestionAnswer;
451
452    #[test]
453    fn formats_keyed_answers() {
454        let answers = vec![
455            QuestionAnswer {
456                header: "Database".to_string(),
457                question: "Which database?".to_string(),
458                selected: vec!["PostgreSQL".to_string()],
459                note: None,
460            },
461            QuestionAnswer {
462                header: "Features".to_string(),
463                question: "Which features?".to_string(),
464                selected: vec!["Auth".to_string(), "Admin".to_string()],
465                note: Some("also add profiles".to_string()),
466            },
467        ];
468        let out = format_answers(&answers);
469        assert!(out.contains("Which database? -> PostgreSQL"));
470        assert!(out.contains("Which features? -> Auth, Admin"));
471        assert!(out.contains("(note: also add profiles)"));
472    }
473
474    #[test]
475    fn empty_selection_reads_as_no_selection() {
476        let answers = vec![QuestionAnswer {
477            header: "Layout".to_string(),
478            question: "Which layout?".to_string(),
479            selected: vec![],
480            note: None,
481        }];
482        assert!(format_answers(&answers).contains("-> (no selection)"));
483        assert_eq!(summarize_answers(&answers), "no selection");
484    }
485
486    #[tokio::test]
487    async fn headless_proceeds_without_broker() {
488        use mermaid_domain::{ToolCallId, TurnId};
489        let (ctx, _rx) = crate::providers::ctx::test_exec_context(
490            TurnId(1),
491            ToolCallId(1),
492            std::env::temp_dir(),
493        );
494        // test_exec_context leaves `questions: None` (headless).
495        let out = AskUserQuestionTool
496            .execute(
497                serde_json::json!({
498                    "questions": [{
499                        "header": "DB",
500                        "question": "Which database?",
501                        "multiSelect": false,
502                        "options": [
503                            {"label": "PostgreSQL", "description": "relational"},
504                            {"label": "SQLite", "description": "embedded"}
505                        ]
506                    }]
507                }),
508                ctx,
509            )
510            .await;
511        assert_eq!(out.status, mermaid_domain::ToolStatus::Success);
512        assert!(out.model_content.contains("Proceed"));
513    }
514
515    #[test]
516    fn prefs_round_trip() {
517        let dir = std::env::temp_dir().join(format!("mermaid_qprefs_{}", std::process::id()));
518        std::fs::create_dir_all(&dir).unwrap();
519        let path = dir.join("prefs.json");
520        let mut map = HashMap::new();
521        map.insert(
522            "pkg_mgr".to_string(),
523            StoredAnswer {
524                selected: vec!["pnpm".to_string()],
525                note: None,
526            },
527        );
528        save_prefs_at(&path, &map);
529        let loaded = load_prefs_at(&path);
530        assert_eq!(
531            loaded.get("pkg_mgr").map(|s| s.selected.clone()),
532            Some(vec!["pnpm".to_string()])
533        );
534        let _ = std::fs::remove_dir_all(&dir);
535    }
536
537    fn keyed(header: &str, memory_key: Option<&str>) -> Question {
538        Question {
539            header: header.to_string(),
540            question: format!("Which {header}?"),
541            kind: QuestionKind::Select,
542            options: Vec::new(),
543            memory_key: memory_key.map(str::to_string),
544        }
545    }
546
547    fn stored(selected: &str) -> StoredAnswer {
548        StoredAnswer {
549            selected: vec![selected.to_string()],
550            note: None,
551        }
552    }
553
554    #[test]
555    fn every_question_remembered_answers_without_asking() {
556        let questions = vec![keyed("Database", Some("db")), keyed("Runtime", Some("rt"))];
557        let prefs = HashMap::from([
558            ("db".to_string(), stored("PostgreSQL")),
559            ("rt".to_string(), stored("tokio")),
560        ]);
561
562        let answers = remembered_answers(&questions, &prefs).expect("both keys are remembered");
563
564        assert_eq!(answers.len(), 2);
565        assert_eq!(answers[0].header, "Database");
566        assert_eq!(answers[0].selected, vec!["PostgreSQL".to_string()]);
567        assert_eq!(answers[1].selected, vec!["tokio".to_string()]);
568    }
569
570    #[test]
571    fn one_unremembered_question_asks_the_whole_set() {
572        let questions = vec![keyed("Database", Some("db")), keyed("Runtime", Some("rt"))];
573        let prefs = HashMap::from([("db".to_string(), stored("PostgreSQL"))]);
574
575        // All-or-nothing: a partially settled set is still worth asking about,
576        // and answering only half of it would be worse than asking twice.
577        assert!(remembered_answers(&questions, &prefs).is_none());
578    }
579
580    /// The case that used to be load-bearing for a distant `unwrap()`: a
581    /// question carrying no `memory_key` at all. The old code checked for it
582    /// three lines above the indexing that assumed the check had run.
583    #[test]
584    fn a_question_without_a_memory_key_asks_rather_than_panicking() {
585        let questions = vec![keyed("Database", Some("db")), keyed("Runtime", None)];
586        let prefs = HashMap::from([("db".to_string(), stored("PostgreSQL"))]);
587
588        assert!(remembered_answers(&questions, &prefs).is_none());
589    }
590
591    #[test]
592    fn an_empty_question_set_is_vacuously_remembered() {
593        // Unreachable through the tool (an empty `questions` array is rejected
594        // earlier), but pinned so the fold's identity cannot drift silently.
595        assert_eq!(
596            remembered_answers(&[], &HashMap::new()).map(|a| a.len()),
597            Some(0)
598        );
599    }
600}