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 crate::domain::{
19    OptionPreview, Question, QuestionAnswer, QuestionKind, QuestionOption, QuestionResolution,
20    TextValidate, ToolDefinition, ToolMetadata, ToolOutcome, ToolRunMetadata,
21};
22
23use super::super::ctx::ExecContext;
24use super::ToolExecutor;
25
26pub struct AskUserQuestionTool;
27
28/// Format the resolved answers into the text the model sees, keyed by question
29/// so a batched call never scrambles which answer belongs to which question.
30fn format_answers(answers: &[QuestionAnswer]) -> String {
31    let mut out = String::from("The user answered your question(s):\n");
32    for a in answers {
33        let value = if a.selected.is_empty() {
34            "(no selection)".to_string()
35        } else {
36            a.selected.join(", ")
37        };
38        out.push_str(&format!("- {} -> {}\n", a.question, value));
39        if let Some(note) = &a.note {
40            out.push_str(&format!("  (note: {})\n", note));
41        }
42    }
43    out
44}
45
46/// Ride the structured answers on the outcome metadata so the transcript can
47/// render each question → answer pair (`ToolMetadata::Questions`) instead of
48/// a bare duration line.
49fn answers_metadata(answers: Vec<QuestionAnswer>, remembered: bool) -> ToolRunMetadata {
50    ToolRunMetadata {
51        detail: ToolMetadata::Questions {
52            answers,
53            remembered,
54        },
55        ..ToolRunMetadata::default()
56    }
57}
58
59/// One-line UI summary of the answers.
60fn summarize_answers(answers: &[QuestionAnswer]) -> String {
61    match answers {
62        [] => "no questions".to_string(),
63        [one] => {
64            if one.selected.is_empty() {
65                "no selection".to_string()
66            } else {
67                one.selected.join(", ")
68            }
69        },
70        many => format!("{} questions answered", many.len()),
71    }
72}
73
74/// Parse the model's flat option JSON into a `QuestionOption`.
75fn parse_option(v: &serde_json::Value) -> Option<QuestionOption> {
76    let label = v.get("label").and_then(|x| x.as_str())?.to_string();
77    let description = v
78        .get("description")
79        .and_then(|x| x.as_str())
80        .map(str::to_string);
81    let preview = v.get("preview").and_then(parse_preview);
82    // Honor the Claude-Code convention: a trailing "(Recommended)" flags it.
83    let recommended = label.to_lowercase().contains("(recommended)");
84    Some(QuestionOption {
85        label,
86        description,
87        recommended,
88        preview,
89    })
90}
91
92fn parse_preview(v: &serde_json::Value) -> Option<OptionPreview> {
93    let content = v.get("content").and_then(|x| x.as_str())?.to_string();
94    let language = v
95        .get("language")
96        .and_then(|x| x.as_str())
97        .map(str::to_string);
98    let diff = v.get("diff").and_then(|x| x.as_bool()).unwrap_or(false);
99    Some(OptionPreview {
100        content,
101        language,
102        diff,
103    })
104}
105
106/// Parse a `text` question's `validate`: the string "number"/"any", any other
107/// string as a regex pattern, or `{ "regex": "..." }`.
108fn parse_validate(v: Option<&serde_json::Value>) -> TextValidate {
109    match v {
110        None => TextValidate::Any,
111        Some(val) => {
112            if let Some(s) = val.as_str() {
113                match s {
114                    "number" => TextValidate::Number,
115                    "any" | "" => TextValidate::Any,
116                    pat => TextValidate::Regex(pat.to_string()),
117                }
118            } else if let Some(pat) = val.get("regex").and_then(|x| x.as_str()) {
119                TextValidate::Regex(pat.to_string())
120            } else {
121                TextValidate::Any
122            }
123        },
124    }
125}
126
127/// Parse one flat question object into a `Question`.
128fn parse_question(v: &serde_json::Value) -> Result<Question, String> {
129    let header = v
130        .get("header")
131        .and_then(|x| x.as_str())
132        .unwrap_or("")
133        .to_string();
134    let question = v
135        .get("question")
136        .and_then(|x| x.as_str())
137        .ok_or("each question needs a `question` string")?
138        .to_string();
139    let kind = match v.get("kind").and_then(|x| x.as_str()).unwrap_or("select") {
140        "select" => QuestionKind::Select,
141        "multiSelect" | "multiselect" => QuestionKind::MultiSelect,
142        "rank" => QuestionKind::Rank,
143        "text" => QuestionKind::Text {
144            validate: parse_validate(v.get("validate")),
145        },
146        "number" => QuestionKind::Number {
147            min: v.get("min").and_then(|x| x.as_f64()),
148            max: v.get("max").and_then(|x| x.as_f64()),
149            step: v.get("step").and_then(|x| x.as_f64()),
150            slider: v.get("slider").and_then(|x| x.as_bool()).unwrap_or(false),
151        },
152        "date" => QuestionKind::Date,
153        "path" => QuestionKind::Path {
154            must_exist: v
155                .get("mustExist")
156                .and_then(|x| x.as_bool())
157                .unwrap_or(false),
158        },
159        other => return Err(format!("unknown question kind: {other}")),
160    };
161    let options = v
162        .get("options")
163        .and_then(|x| x.as_array())
164        .map(|arr| arr.iter().filter_map(parse_option).collect::<Vec<_>>())
165        .unwrap_or_default();
166    let memory_key = v
167        .get("memoryKey")
168        .and_then(|x| x.as_str())
169        .filter(|s| !s.is_empty())
170        .map(str::to_string);
171    let q = Question {
172        header,
173        question,
174        kind,
175        options,
176        memory_key,
177    };
178    if q.is_choice() && q.options.is_empty() {
179        return Err(format!(
180            "question \"{}\" is a choice kind but has no options",
181            q.question
182        ));
183    }
184    Ok(q)
185}
186
187/// A remembered answer, persisted across sessions keyed by a question's
188/// `memory_key`.
189#[derive(serde::Serialize, serde::Deserialize, Clone)]
190struct StoredAnswer {
191    selected: Vec<String>,
192    #[serde(default)]
193    note: Option<String>,
194}
195
196fn prefs_path() -> Option<PathBuf> {
197    crate::app::get_config_dir()
198        .ok()
199        .map(|d| d.join("question_prefs.json"))
200}
201
202fn load_prefs_at(path: &Path) -> HashMap<String, StoredAnswer> {
203    std::fs::read_to_string(path)
204        .ok()
205        .and_then(|s| serde_json::from_str(&s).ok())
206        .unwrap_or_default()
207}
208
209fn save_prefs_at(path: &Path, map: &HashMap<String, StoredAnswer>) {
210    let json = match serde_json::to_string_pretty(map) {
211        Ok(json) => json,
212        Err(err) => {
213            tracing::warn!(error = %err, "ask_user_question: failed to serialize answer prefs");
214            return;
215        },
216    };
217    // Atomic write so a crash mid-save can't truncate the prefs file, and log on
218    // failure instead of silently losing the user's "remember this answer" choice.
219    if let Err(err) = crate::runtime::write_atomic(path, json.as_bytes()) {
220        tracing::warn!(
221            error = %err,
222            path = %path.display(),
223            "ask_user_question: failed to persist answer prefs"
224        );
225    }
226}
227
228fn load_prefs() -> HashMap<String, StoredAnswer> {
229    prefs_path().map(|p| load_prefs_at(&p)).unwrap_or_default()
230}
231
232fn save_prefs(map: &HashMap<String, StoredAnswer>) {
233    if let Some(p) = prefs_path() {
234        save_prefs_at(&p, map);
235    }
236}
237
238#[async_trait]
239impl ToolExecutor for AskUserQuestionTool {
240    fn name(&self) -> &'static str {
241        "ask_user_question"
242    }
243
244    fn schema(&self) -> ToolDefinition {
245        ToolDefinition {
246            name: "ask_user_question".to_string(),
247            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. \
248                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. \
249                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)\". \
250                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. \
251                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."
252                .to_string(),
253            input_schema: serde_json::json!({
254                "type": "object",
255                "properties": {
256                    "questions": {
257                        "type": "array",
258                        "description": "1-4 questions to ask, shown together.",
259                        "items": {
260                            "type": "object",
261                            "properties": {
262                                "header": {
263                                    "type": "string",
264                                    "description": "Very short label (<=12 chars) shown as a chip, e.g. \"Database\"."
265                                },
266                                "question": {
267                                    "type": "string",
268                                    "description": "The full question text. Clear, specific, ends with a question mark."
269                                },
270                                "kind": {
271                                    "type": "string",
272                                    "enum": ["select", "multiSelect", "rank", "text", "number", "date", "path"],
273                                    "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`."
274                                },
275                                "validate": {
276                                    "type": "string",
277                                    "description": "For `text`: \"number\" to require a number, or a regex pattern the answer must match."
278                                },
279                                "min": { "type": "number", "description": "For `number`: minimum value." },
280                                "max": { "type": "number", "description": "For `number`: maximum value." },
281                                "step": { "type": "number", "description": "For `number`: increment for Up/Down." },
282                                "slider": { "type": "boolean", "description": "For `number`: show a slider bar (needs `min` and `max`)." },
283                                "mustExist": { "type": "boolean", "description": "For `path`: hint that the path should already exist." },
284                                "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)." },
285                                "options": {
286                                    "type": "array",
287                                    "description": "Options for choice kinds (select/multiSelect/rank); omit for input kinds. Two or more; long lists scroll.",
288                                    "items": {
289                                        "type": "object",
290                                        "properties": {
291                                            "label": {
292                                                "type": "string",
293                                                "description": "Concise choice text. End with \"(Recommended)\" to flag your suggestion."
294                                            },
295                                            "description": {
296                                                "type": "string",
297                                                "description": "One line explaining the option or its trade-off."
298                                            },
299                                            "preview": {
300                                                "type": "object",
301                                                "description": "Optional side-by-side preview shown when this option is focused.",
302                                                "properties": {
303                                                    "content": { "type": "string", "description": "The preview body, shown as monospace lines." },
304                                                    "language": { "type": "string", "description": "Language hint for the content (e.g. \"rust\", \"yaml\")." },
305                                                    "diff": { "type": "boolean", "description": "Render content as a unified diff (+ lines green, - lines red). Best for showing the change an option would produce." }
306                                                },
307                                                "required": ["content"]
308                                            }
309                                        },
310                                        "required": ["label", "description"]
311                                    }
312                                }
313                            },
314                            "required": ["question", "header", "kind"]
315                        }
316                    }
317                },
318                "required": ["questions"]
319            }),
320        }
321    }
322
323    async fn execute(&self, args: serde_json::Value, ctx: ExecContext) -> ToolOutcome {
324        let start = Instant::now();
325        let secs = || start.elapsed().as_secs_f64();
326
327        let Some(questions_val) = args.get("questions").and_then(|v| v.as_array()) else {
328            return ToolOutcome::error("ask_user_question requires a `questions` array", secs());
329        };
330        if questions_val.is_empty() {
331            return ToolOutcome::error("`questions` must contain at least one question", secs());
332        }
333        let mut questions = Vec::with_capacity(questions_val.len());
334        for qv in questions_val {
335            match parse_question(qv) {
336                Ok(q) => questions.push(q),
337                Err(e) => return ToolOutcome::error(format!("invalid question: {e}"), secs()),
338            }
339        }
340
341        // Cross-session preferences: if every question already has a remembered
342        // answer, return them without prompting (works interactively and
343        // headlessly) so settled decisions aren't re-asked every session.
344        let prefs = load_prefs();
345        if questions
346            .iter()
347            .all(|q| q.memory_key.as_ref().is_some_and(|k| prefs.contains_key(k)))
348        {
349            let answers: Vec<QuestionAnswer> = questions
350                .iter()
351                .map(|q| {
352                    let stored = &prefs[q.memory_key.as_ref().unwrap()];
353                    QuestionAnswer {
354                        header: q.header.clone(),
355                        question: q.question.clone(),
356                        selected: stored.selected.clone(),
357                        note: stored.note.clone(),
358                    }
359                })
360                .collect();
361            return ToolOutcome::success(
362                format_answers(&answers),
363                format!("{} (remembered)", summarize_answers(&answers)),
364                secs(),
365            )
366            .with_metadata(answers_metadata(answers, true));
367        }
368
369        let Some(broker) = ctx.questions.as_ref() else {
370            // Headless / no interactive terminal: nobody to ask. Proceed rather
371            // than block an automated run.
372            return ToolOutcome::success(
373                "No interactive terminal is available, so the user could not be asked. \
374                 Proceed using your best judgment and state the assumption you made.",
375                "no interactive terminal; proceeding without answers",
376                secs(),
377            );
378        };
379
380        match broker
381            .request(&ctx.token, ctx.turn, ctx.call_id, questions.clone())
382            .await
383        {
384            QuestionResolution::Answered { answers, remember } => {
385                if remember {
386                    let mut prefs = prefs;
387                    for (q, a) in questions.iter().zip(&answers) {
388                        if let Some(key) = &q.memory_key
389                            && !a.selected.is_empty()
390                        {
391                            prefs.insert(
392                                key.clone(),
393                                StoredAnswer {
394                                    selected: a.selected.clone(),
395                                    note: a.note.clone(),
396                                },
397                            );
398                        }
399                    }
400                    save_prefs(&prefs);
401                }
402                ToolOutcome::success(
403                    format_answers(&answers),
404                    summarize_answers(&answers),
405                    secs(),
406                )
407                .with_metadata(answers_metadata(answers, false))
408            },
409            QuestionResolution::Dismissed => ToolOutcome::success(
410                "The user dismissed the question(s) without answering. Do not re-ask unless you \
411                 still need the information; otherwise proceed with your best judgment.",
412                "dismissed without answering",
413                secs(),
414            ),
415            QuestionResolution::Reformulate => ToolOutcome::success(
416                "The user chose to discuss these questions rather than answer them as posed. \
417                 Do not re-issue the same questions; engage with what they say next and \
418                 reformulate your approach based on their input.",
419                "user chose to chat about this instead",
420                secs(),
421            ),
422        }
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::domain::QuestionAnswer;
430
431    #[test]
432    fn formats_keyed_answers() {
433        let answers = vec![
434            QuestionAnswer {
435                header: "Database".to_string(),
436                question: "Which database?".to_string(),
437                selected: vec!["PostgreSQL".to_string()],
438                note: None,
439            },
440            QuestionAnswer {
441                header: "Features".to_string(),
442                question: "Which features?".to_string(),
443                selected: vec!["Auth".to_string(), "Admin".to_string()],
444                note: Some("also add profiles".to_string()),
445            },
446        ];
447        let out = format_answers(&answers);
448        assert!(out.contains("Which database? -> PostgreSQL"));
449        assert!(out.contains("Which features? -> Auth, Admin"));
450        assert!(out.contains("(note: also add profiles)"));
451    }
452
453    #[test]
454    fn empty_selection_reads_as_no_selection() {
455        let answers = vec![QuestionAnswer {
456            header: "Layout".to_string(),
457            question: "Which layout?".to_string(),
458            selected: vec![],
459            note: None,
460        }];
461        assert!(format_answers(&answers).contains("-> (no selection)"));
462        assert_eq!(summarize_answers(&answers), "no selection");
463    }
464
465    #[tokio::test]
466    async fn headless_proceeds_without_broker() {
467        use crate::domain::{ToolCallId, TurnId};
468        let (ctx, _rx) = crate::providers::ctx::test_exec_context(
469            TurnId(1),
470            ToolCallId(1),
471            std::env::temp_dir(),
472        );
473        // test_exec_context leaves `questions: None` (headless).
474        let out = AskUserQuestionTool
475            .execute(
476                serde_json::json!({
477                    "questions": [{
478                        "header": "DB",
479                        "question": "Which database?",
480                        "multiSelect": false,
481                        "options": [
482                            {"label": "PostgreSQL", "description": "relational"},
483                            {"label": "SQLite", "description": "embedded"}
484                        ]
485                    }]
486                }),
487                ctx,
488            )
489            .await;
490        assert_eq!(out.status, crate::domain::ToolStatus::Success);
491        assert!(out.model_content.contains("Proceed"));
492    }
493
494    #[test]
495    fn prefs_round_trip() {
496        let dir = std::env::temp_dir().join(format!("mermaid_qprefs_{}", std::process::id()));
497        std::fs::create_dir_all(&dir).unwrap();
498        let path = dir.join("prefs.json");
499        let mut map = HashMap::new();
500        map.insert(
501            "pkg_mgr".to_string(),
502            StoredAnswer {
503                selected: vec!["pnpm".to_string()],
504                note: None,
505            },
506        );
507        save_prefs_at(&path, &map);
508        let loaded = load_prefs_at(&path);
509        assert_eq!(
510            loaded.get("pkg_mgr").map(|s| s.selected.clone()),
511            Some(vec!["pnpm".to_string()])
512        );
513        let _ = std::fs::remove_dir_all(&dir);
514    }
515}