1use 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
28fn 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
46fn 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
59fn 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
74fn 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 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
106fn 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
127fn 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#[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 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 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 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 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}