cosh_tools/question/mod.rs
1//! Tool for asking structured questions to the user.
2//!
3//! [`Question`] provides a way for LLM agents to ask the user for
4//! information in a structured, efficient way. Supports free-text and
5//! choice questions (single/multi select) and allows asking several
6//! questions at once to minimize back-and-forth.
7//!
8//! # Design principles
9//!
10//! - **Market-standard wire format**: the schema mirrors Claude Code's
11//! `AskUserQuestion` (and its ports — Qwen Code, OpenCode, Crush): a
12//! `questions` array of `{ question, header, options: [{label,
13//! description}], multiSelect }` items. Models trained on any of those
14//! harnesses emit a valid call on the first attempt.
15//! - **Lenient decoding of model emission variants**: plain-string options,
16//! string-encoded arrays, the `multiple` alias, and header-only items all
17//! parse — see [`types::QuestionItem`]'s custom deserializer. These are
18//! model behaviors, not alternate schemas.
19//! - **Batch questions**: ask multiple questions in a single call.
20//! - **Purpose field**: each question may explain *why* the information is
21//! needed.
22//! - **Recommendations by convention**: the recommended option is the FIRST
23//! option carrying a `" (Recommended)"` label suffix — the convention
24//! models already know. The suffix is stripped wherever it appears and
25//! turned into the internal `recommended` badge.
26//!
27//! # Example
28//!
29//! ```ignore
30//! use cosh_tools::question::Question;
31//! use cosh_tools::question::types::QuestionInput;
32//!
33//! let q = Question::new();
34//! let input: QuestionInput = serde_json::from_value(serde_json::json!({
35//! "questions": [{
36//! "question": "Which auth method?",
37//! "header": "Auth method",
38//! "options": [
39//! {"label": "OAuth 2.0 (Recommended)", "description": "Browser flow"},
40//! {"label": "API key", "description": "Static token"}
41//! ]
42//! }]
43//! }))?;
44//! let output = q.ask(&input)?;
45//! ```
46
47pub mod types;
48
49#[cfg(test)]
50mod test;
51
52use types::{CUSTOM_RESPONSE_LABEL, QuestionInput, QuestionOutput, QuestionType};
53
54use crate::ToolDescription;
55
56/// Tool for asking structured questions to the user.
57///
58/// Use the [`ask`](Self::ask) method to present questions and collect
59/// answers. The tool is stateless — the TUI layer is responsible for
60/// showing the dialog and capturing user input.
61pub struct Question {
62 /// MCP Tool description for `ask_questions`.
63 pub description_ask: ToolDescription,
64}
65
66impl Default for Question {
67 fn default() -> Self {
68 Self::new()
69 }
70}
71
72/// Canonical example embedded in the tool description. The harness feeds
73/// this to the model in schema-rejection hints (`exampleArgs`), so a failed
74/// call is corrected with a complete, copy-pasteable payload rather than a
75/// type-only skeleton.
76const EXAMPLE_ARGS: &str = r#"{"questions": [
77 {
78 "question": "Which authentication method should we use?",
79 "header": "Auth method",
80 "multiSelect": false,
81 "options": [
82 {"label": "OAuth 2.0 (Recommended)", "description": "Industry standard, supports multiple providers"},
83 {"label": "API key", "description": "Stateless, good for simple server-to-server calls"}
84 ]
85 },
86 {
87 "question": "Which sections should I include?",
88 "header": "Sections",
89 "multiSelect": true,
90 "options": [
91 {"label": "Introduction", "description": "Opening context"},
92 {"label": "Conclusion", "description": "Final summary"}
93 ]
94 }
95]}"#;
96
97impl Question {
98 /// Create a new `Question` with the tool description pre-configured.
99 #[must_use]
100 pub fn new() -> Self {
101 Self {
102 description_ask: serde_json::json!( {
103 "name": "ask_questions",
104 "description": concat!(
105 "Ask the user one or more questions and collect their answers. ",
106 "Ask ALL questions you need in a single call — batch your ",
107 "questions to minimize back-and-forth and avoid frustrating ",
108 "the user with repeated interruptions. Use the 'purpose' field ",
109 "to explain WHY you need each piece of information so the user ",
110 "understands the context and can give better answers.\n\n",
111 "Each question has:\n",
112 "- **question**: the complete question text, clear, specific, ",
113 "ending with a question mark.\n",
114 "- **header**: very short label displayed as a chip/tag (max 12 ",
115 "chars). Examples: \"Auth method\", \"Library\", \"Approach\".\n",
116 "- **options**: 2-4 distinct choices, each {\"label\": display ",
117 "text (1-5 words), \"description\": what the option means or its ",
118 "trade-off}. Omit 'options' entirely for a free-text question — ",
119 "the user can always type a custom answer instead of picking.\n",
120 "- **multiSelect**: set to true to allow the user to select ",
121 "multiple options (default false — mutually exclusive choices).\n\n",
122 "Do NOT add your own 'Other'/custom option — free text is offered ",
123 "automatically. If you recommend an option, make it the FIRST ",
124 "option and append \" (Recommended)\" to its label.\n\n",
125 "Rule of thumb: if you can reasonably infer the answer from ",
126 "context, do NOT ask — use your existing knowledge or search ",
127 "tools instead. Only ask when you genuinely need user input."
128 ),
129 "inputSchema": {
130 "type": "object",
131 "properties": {
132 "questions": {
133 "type": "array",
134 "description": "Questions to ask the user (1-4 questions). Ask ALL questions at once.",
135 "minItems": 1,
136 "maxItems": 4,
137 "items": {
138 "type": "object",
139 "properties": {
140 "question": {
141 "type": "string",
142 "description": "The complete question to ask the user. Should be clear, specific, and end with a question mark. If multiSelect is true, phrase it accordingly, e.g. \"Which features do you want to enable?\""
143 },
144 "header": {
145 "type": "string",
146 "description": "Very short label displayed as a chip/tag (max 12 chars). Examples: \"Auth method\", \"Library\", \"Approach\"."
147 },
148 "options": {
149 "type": "array",
150 "description": "The available choices for this question (2-4 options). Each option: {\"label\": concise display text (1-5 words), \"description\": what this option means or its trade-off}. Omit for a free-text question. Do NOT add an 'Other' option — that is provided automatically.",
151 "items": {
152 "type": "object",
153 "properties": {
154 "label": {
155 "type": "string",
156 "description": "The display text for this option that the user will see and select. Should be concise (1-5 words)."
157 },
158 "description": {
159 "type": "string",
160 "description": "Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications."
161 }
162 },
163 "required": ["label"]
164 }
165 },
166 "multiSelect": {
167 "type": "boolean",
168 "default": false,
169 "description": "Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive."
170 },
171 "purpose": {
172 "type": "string",
173 "description": "Why the agent needs this information. Helps the user understand context."
174 },
175 "required": {
176 "type": "boolean",
177 "default": true,
178 "description": "Whether an answer is required. Defaults to true."
179 }
180 },
181 "required": ["question"]
182 }
183 }
184 },
185 "required": ["questions"]
186 },
187 "exampleArgs": serde_json::from_str::<serde_json::Value>(EXAMPLE_ARGS)
188 .expect("EXAMPLE_ARGS is valid JSON"),
189 }),
190 }
191 }
192
193 /// Present questions to the user and collect their answers.
194 ///
195 /// Returns the user's answers as a structured [`QuestionOutput`].
196 /// The TUI layer intercepts the tool call, renders the dialog,
197 /// captures user input, and returns it as the tool result.
198 ///
199 /// Single-select questions are normalized: the recommended option (the
200 /// option carrying a `" (Recommended)"` label suffix, per the market
201 /// convention) is stripped, badged, and moved to the top.
202 ///
203 /// # Errors
204 ///
205 /// Validation only rejects what cannot be rendered sensibly:
206 /// - The questions array is empty.
207 /// - A choice question has zero options.
208 /// - A single-select option collides with the reserved
209 /// [`types::CUSTOM_RESPONSE_LABEL`] (the TUI renders that label as
210 /// its own virtual row, so such an option would be unreachable).
211 ///
212 /// Everything else is normalized: empty `options` on a free-text
213 /// question are cleared, and a [`types::RECOMMENDED_SUFFIX`] label
214 /// naming an option that no longer exists is dropped.
215 pub fn ask(&self, input: &QuestionInput) -> Result<QuestionOutput, String> {
216 let questions = Self::validate_and_normalize(input)?;
217 Ok(QuestionOutput {
218 questions,
219 answers: Vec::new(),
220 })
221 }
222
223 /// Validate `input` and return the normalized questions.
224 ///
225 /// Normalization: derive the per-question kind and stable ids, strip
226 /// [`types::RECOMMENDED_SUFFIX`] labels into the internal
227 /// `recommended` badge, clear empty option lists on free-text questions,
228 /// drop recommendations that name no option, and move the recommended
229 /// single-select option to position 0. Shared by [`Self::ask`] and the
230 /// harness agent loop so both paths see identical validation and
231 /// ordering.
232 ///
233 /// # Errors
234 ///
235 /// Same error contract as [`Self::ask`].
236 pub fn validate_and_normalize(
237 input: &QuestionInput,
238 ) -> Result<Vec<types::QuestionItem>, String> {
239 if input.questions.is_empty() {
240 return Err("No questions provided. Ask at least one question.".into());
241 }
242
243 // Derive the canonical fields the wire leaves implicit.
244 let mut finalized = input.clone();
245 finalized.finalize();
246 let mut out = finalized.questions;
247
248 for q in &mut out {
249 if q.question_type.is_choice() {
250 let opts = q.options.as_ref().ok_or_else(|| {
251 format!(
252 "Question '{}' is a choice question but has no 'options' — \
253 provide 2-4 options or omit 'options' for free text",
254 q.question
255 )
256 })?;
257 if opts.is_empty() {
258 return Err(format!(
259 "Question '{}' has zero options. Provide at least one option.",
260 q.question
261 ));
262 }
263 // Only single-select renders the virtual custom-answer row,
264 // so only its option list can collide with the reserved label.
265 if q.question_type == QuestionType::SingleChoice
266 && let Some(clash) = opts.iter().find(|o| o.label == CUSTOM_RESPONSE_LABEL)
267 {
268 return Err(format!(
269 "Question '{}' has option '{}' which collides with the \
270 reserved custom-answer label. Do not add your own custom/other \
271 entry — the UI always offers free text itself.",
272 q.question, clash.label
273 ));
274 }
275 } else if q.options.as_ref().is_some_and(|o| o.is_empty()) {
276 // Free-text questions render no choice list.
277 q.options = None;
278 }
279
280 // A recommendation must name a real option; a stale/wrong label
281 // is dropped (with the batch still accepted) rather than
282 // burning an entire attempt.
283 if let Some(rec) = &q.recommended
284 && let Some(opts) = &q.options
285 && !opts.iter().any(|o| o.label == *rec)
286 {
287 q.recommended = None;
288 }
289 }
290
291 // Move the recommended single-select option to the top so the TUI
292 // renders it first (with the Recommended badge).
293 for q in &mut out {
294 if q.question_type == QuestionType::SingleChoice
295 && let Some(rec) = q.recommended.clone()
296 && let Some(opts) = &mut q.options
297 && let Some(pos) = opts.iter().position(|o| o.label == rec)
298 && pos != 0
299 {
300 let item = opts.remove(pos);
301 opts.insert(0, item);
302 }
303 }
304 Ok(out)
305 }
306}