1use async_trait::async_trait;
32use serde::{Deserialize, Serialize};
33use serde_json::{json, Value};
34
35use crate::error::{Error, Result};
36use crate::mcp::{ElicitationAction, ElicitationRequest};
37use crate::tools::{Tool, ToolContext};
38
39pub const ASK_USER: &str = "ask_user";
41
42pub const REQUEST_USER_INPUT: &str = "request_user_input";
45
46#[derive(Clone)]
50pub struct UserQuestionHandler(pub std::sync::Arc<dyn crate::mcp::McpElicitationHandler>);
51
52impl std::fmt::Debug for UserQuestionHandler {
53 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54 f.write_str("UserQuestionHandler(..)")
55 }
56}
57
58impl std::ops::Deref for UserQuestionHandler {
59 type Target = dyn crate::mcp::McpElicitationHandler;
60 fn deref(&self) -> &Self::Target {
61 &*self.0
62 }
63}
64
65pub const MAX_QUESTIONS: usize = 4;
67
68pub const MAX_OPTIONS: usize = 4;
70
71#[derive(Debug, Clone, Deserialize, Serialize)]
73pub struct QuestionOption {
74 pub label: String,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub description: Option<String>,
79}
80
81#[derive(Debug, Clone, Deserialize, Serialize)]
83pub struct Question {
84 pub question: String,
86 #[serde(default)]
89 pub header: String,
90 #[serde(default, rename = "multiSelect", alias = "multi_select")]
92 pub multi_select: bool,
93 #[serde(default)]
95 pub options: Vec<QuestionOption>,
96}
97
98#[derive(Debug, Deserialize)]
99struct AskUserArgs {
100 questions: Vec<Question>,
101}
102
103#[derive(Debug, Clone)]
107pub struct AskUserTool {
108 name: &'static str,
109}
110
111impl AskUserTool {
112 pub fn new(name: &'static str) -> Self {
115 AskUserTool { name }
116 }
117}
118
119impl Default for AskUserTool {
120 fn default() -> Self {
121 AskUserTool::new(ASK_USER)
122 }
123}
124
125fn requested_schema(questions: &[Question]) -> Value {
133 let mut properties = serde_json::Map::new();
134 let mut required = Vec::new();
135 for (index, q) in questions.iter().enumerate() {
136 let key = format!("q{}", index + 1);
137 let labels: Vec<&str> = q.options.iter().map(|o| o.label.as_str()).collect();
138 let mut description = q.question.clone();
139 if !labels.is_empty() {
140 description.push_str(&format!(
141 " (options: {}; free text is also accepted)",
142 labels.join(" | ")
143 ));
144 }
145 let mut prop = json!({
146 "title": if q.header.is_empty() { q.question.clone() } else { q.header.clone() },
147 "description": description,
148 "x-options": q.options,
149 "x-multi-select": q.multi_select,
150 });
151 if q.multi_select {
152 prop["type"] = json!("array");
153 prop["items"] = json!({"type": "string"});
154 } else {
155 prop["type"] = json!("string");
156 }
157 properties.insert(key.clone(), prop);
158 required.push(key);
159 }
160 json!({
161 "type": "object",
162 "properties": properties,
163 "required": required,
164 })
165}
166
167fn message_for(questions: &[Question]) -> String {
170 let mut out = String::new();
171 for (index, q) in questions.iter().enumerate() {
172 if index > 0 {
173 out.push_str("\n\n");
174 }
175 if !q.header.is_empty() {
176 out.push_str(&format!("[{}] ", q.header));
177 }
178 out.push_str(&q.question);
179 for opt in &q.options {
180 out.push_str(&format!("\n - {}", opt.label));
181 if let Some(d) = &opt.description {
182 out.push_str(&format!(" — {d}"));
183 }
184 }
185 if q.multi_select {
186 out.push_str("\n (multiple selections allowed)");
187 }
188 }
189 out
190}
191
192fn format_answers(questions: &[Question], content: &Value) -> String {
196 let mut lines = Vec::new();
197 for (index, q) in questions.iter().enumerate() {
198 let key = format!("q{}", index + 1);
199 let answer = content.get(&key).map(render_answer).unwrap_or_else(|| {
200 content
201 .get(&q.header)
202 .map(render_answer)
203 .unwrap_or_else(|| "(no answer)".to_string())
204 });
205 let label = if q.header.is_empty() {
206 q.question.clone()
207 } else {
208 q.header.clone()
209 };
210 lines.push(format!("{label}: {answer}"));
211 }
212 format!(
213 "The user answered:\n{}\n\nraw: {}",
214 lines.join("\n"),
215 content
216 )
217}
218
219fn render_answer(v: &Value) -> String {
220 match v {
221 Value::String(s) => s.clone(),
222 Value::Array(items) => items
223 .iter()
224 .map(render_answer)
225 .collect::<Vec<_>>()
226 .join(", "),
227 other => other.to_string(),
228 }
229}
230
231#[async_trait]
232impl Tool for AskUserTool {
233 fn name(&self) -> &str {
234 self.name
235 }
236 fn description(&self) -> &str {
237 "Ask the user 1-4 structured questions and wait for the answers. Use it when a \
238 decision is genuinely the user's to make (a choice between real alternatives, a \
239 missing fact only they have) — never to ask permission for work you were already \
240 asked to do. Each question offers labelled options; the user may also answer in \
241 free text."
242 }
243 fn parameters(&self) -> Value {
244 json!({
245 "type": "object",
246 "properties": {
247 "questions": {
248 "type": "array",
249 "minItems": 1,
250 "maxItems": MAX_QUESTIONS,
251 "description": "1-4 questions to ask at once.",
252 "items": {
253 "type": "object",
254 "properties": {
255 "question": {"type": "string", "description": "The question text."},
256 "header": {
257 "type": "string",
258 "description": "Short label (a few words) naming what is being decided."
259 },
260 "multiSelect": {
261 "type": "boolean",
262 "description": "Whether the user may pick more than one option."
263 },
264 "options": {
265 "type": "array",
266 "maxItems": MAX_OPTIONS,
267 "items": {
268 "type": "object",
269 "properties": {
270 "label": {"type": "string"},
271 "description": {"type": "string"}
272 },
273 "required": ["label"],
274 "additionalProperties": false
275 }
276 }
277 },
278 "required": ["question", "options"],
279 "additionalProperties": false
280 }
281 }
282 },
283 "required": ["questions"],
284 "additionalProperties": false
285 })
286 }
287 async fn execute(&self, args: Value, ctx: &ToolContext) -> Result<String> {
288 let a: AskUserArgs = serde_json::from_value(args).map_err(|e| Error::InvalidArguments {
289 tool: self.name().to_string(),
290 message: e.to_string(),
291 })?;
292 if a.questions.is_empty() || a.questions.len() > MAX_QUESTIONS {
293 return Err(Error::InvalidArguments {
294 tool: self.name().to_string(),
295 message: format!(
296 "ask between 1 and {MAX_QUESTIONS} questions in one call (got {})",
297 a.questions.len()
298 ),
299 });
300 }
301 for q in &a.questions {
302 if q.question.trim().is_empty() {
303 return Err(Error::InvalidArguments {
304 tool: self.name().to_string(),
305 message: "every question needs non-empty text".to_string(),
306 });
307 }
308 if q.options.len() > MAX_OPTIONS {
309 return Err(Error::InvalidArguments {
310 tool: self.name().to_string(),
311 message: format!("at most {MAX_OPTIONS} options per question"),
312 });
313 }
314 if q.options.iter().any(|o| o.label.trim().is_empty()) {
315 return Err(Error::InvalidArguments {
316 tool: self.name().to_string(),
317 message: "every option needs a non-empty label".to_string(),
318 });
319 }
320 }
321 let Some(handler) = ctx.question_handler.as_ref() else {
325 return Err(Error::tool(
326 self.name(),
327 "no interactive frontend is attached, so the user cannot be asked (headless \
328 run): make the best decision you can and say which assumption you made",
329 ));
330 };
331 let request = ElicitationRequest {
332 message: message_for(&a.questions),
333 requested_schema: requested_schema(&a.questions),
334 };
335 let response = handler.handle(&request).await;
336 match response.action {
337 ElicitationAction::Accept => {
338 let content = response.content.unwrap_or_else(|| json!({}));
339 Ok(format_answers(&a.questions, &content))
340 }
341 ElicitationAction::Decline => Ok(
342 "The user declined to answer. Proceed with your own best judgement and say \
343 what you assumed."
344 .to_string(),
345 ),
346 ElicitationAction::Cancel => Ok("The user dismissed the question without \
347 answering. Proceed with your own best judgement \
348 and say what you assumed."
349 .to_string()),
350 }
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::mcp::{ElicitationResponse, McpElicitationHandler};
358 use std::sync::Arc;
359
360 struct Answering(Value);
361
362 #[async_trait]
363 impl McpElicitationHandler for Answering {
364 async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
365 ElicitationResponse {
366 action: ElicitationAction::Accept,
367 content: Some(self.0.clone()),
368 }
369 }
370 }
371
372 struct Declining;
373
374 #[async_trait]
375 impl McpElicitationHandler for Declining {
376 async fn handle(&self, _request: &ElicitationRequest) -> ElicitationResponse {
377 ElicitationResponse {
378 action: ElicitationAction::Decline,
379 content: None,
380 }
381 }
382 }
383
384 fn one_question() -> Value {
385 json!({
386 "questions": [{
387 "question": "Which database?",
388 "header": "Database",
389 "options": [{"label": "postgres"}, {"label": "sqlite", "description": "local"}]
390 }]
391 })
392 }
393
394 #[tokio::test]
395 async fn headless_is_deny_default() {
396 let ctx = ToolContext::new(std::env::temp_dir());
397 let err = AskUserTool::default()
398 .execute(one_question(), &ctx)
399 .await
400 .expect_err("no handler must refuse");
401 assert!(err.to_string().contains("no interactive frontend"), "{err}");
402 }
403
404 #[tokio::test]
405 async fn an_answer_comes_back_to_the_model() {
406 let mut ctx = ToolContext::new(std::env::temp_dir());
407 ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
408 json!({"q1": "sqlite"}),
409 ))));
410 let out = AskUserTool::default()
411 .execute(one_question(), &ctx)
412 .await
413 .unwrap();
414 assert!(out.contains("Database: sqlite"), "{out}");
415 }
416
417 #[tokio::test]
418 async fn multi_select_answers_render_as_a_list() {
419 let mut ctx = ToolContext::new(std::env::temp_dir());
420 ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
421 json!({"q1": ["a", "b"]}),
422 ))));
423 let out = AskUserTool::default()
424 .execute(
425 json!({"questions": [{
426 "question": "Which ones?",
427 "header": "Targets",
428 "multiSelect": true,
429 "options": [{"label": "a"}, {"label": "b"}]
430 }]}),
431 &ctx,
432 )
433 .await
434 .unwrap();
435 assert!(out.contains("Targets: a, b"), "{out}");
436 }
437
438 #[tokio::test]
439 async fn free_text_is_accepted_even_when_it_matches_no_option() {
440 let mut ctx = ToolContext::new(std::env::temp_dir());
441 ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(
442 json!({"q1": "duckdb, actually"}),
443 ))));
444 let out = AskUserTool::default()
445 .execute(one_question(), &ctx)
446 .await
447 .unwrap();
448 assert!(out.contains("duckdb, actually"), "{out}");
449 }
450
451 #[tokio::test]
452 async fn a_decline_is_reported_not_invented() {
453 let mut ctx = ToolContext::new(std::env::temp_dir());
454 ctx.question_handler = Some(UserQuestionHandler(Arc::new(Declining)));
455 let out = AskUserTool::default()
456 .execute(one_question(), &ctx)
457 .await
458 .unwrap();
459 assert!(out.contains("declined"), "{out}");
460 }
461
462 #[tokio::test]
463 async fn more_than_four_questions_is_refused() {
464 let mut ctx = ToolContext::new(std::env::temp_dir());
465 ctx.question_handler = Some(UserQuestionHandler(Arc::new(Answering(json!({})))));
466 let many: Vec<Value> = (0..5)
467 .map(|i| json!({"question": format!("q{i}"), "options": []}))
468 .collect();
469 let err = AskUserTool::default()
470 .execute(json!({"questions": many}), &ctx)
471 .await
472 .expect_err("five questions must be refused");
473 assert!(err.to_string().contains("between 1 and 4"), "{err}");
474 }
475
476 #[test]
477 fn the_requested_schema_never_constrains_the_answer_to_an_enum() {
478 let questions = vec![Question {
479 question: "Which database?".into(),
480 header: "Database".into(),
481 multi_select: false,
482 options: vec![QuestionOption {
483 label: "postgres".into(),
484 description: None,
485 }],
486 }];
487 let schema = requested_schema(&questions);
488 let prop = &schema["properties"]["q1"];
489 assert_eq!(prop["type"], "string");
490 assert!(prop.get("enum").is_none(), "free text must stay possible");
491 assert_eq!(prop["x-options"][0]["label"], "postgres");
492 }
493
494 #[test]
495 fn the_cx_alias_keeps_its_own_registered_name() {
496 assert_eq!(
497 AskUserTool::new(REQUEST_USER_INPUT).name(),
498 "request_user_input"
499 );
500 assert_eq!(AskUserTool::default().name(), "ask_user");
501 }
502}