返回 CodeWhale
user_input.rs
根目录 / crates / tui / src / tools / user_input.rs
1 //! Tool and types for requesting user input via the TUI.
2
3 use super::spec::{
4 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
5 };
6 use async_trait::async_trait;
7 use serde::{Deserialize, Serialize};
8 use serde_json::{Value, json};
9
10 /// Default ceiling on `request_user_input.questions` (#5949). Raised from the
11 /// former hard-coded 3 so research/planning turns that need four or more
12 /// clarifications are not rejected outright.
13 pub const DEFAULT_MAX_QUESTIONS: usize = 6;
14 /// Default ceiling on options per question.
15 pub const DEFAULT_MAX_OPTIONS: usize = 4;
16 /// Inclusive bounds a configured `user_input_max_questions` is clamped into.
17 pub const MIN_CONFIGURABLE_QUESTIONS: usize = 1;
18 pub const MAX_CONFIGURABLE_QUESTIONS: usize = 10;
19 /// Inclusive bounds a configured `user_input_max_options` is clamped into.
20 /// The floor is 2 because a one-option question is not a choice.
21 pub const MIN_CONFIGURABLE_OPTIONS: usize = 2;
22 pub const MAX_CONFIGURABLE_OPTIONS: usize = 10;
23
24 /// Config key naming used in both the clamp WARN and the rejection message, so
25 /// a model that hits the ceiling is told exactly where to raise it.
26 pub const MAX_QUESTIONS_KEY: &str = "[tools] user_input_max_questions";
27 pub const MAX_OPTIONS_KEY: &str = "[tools] user_input_max_options";
28
29 /// Effective `request_user_input` payload ceilings for one session.
30 ///
31 /// Resolved once from `[tools]` in config.toml (see
32 /// [`crate::config::Config::user_input_limits`]) and carried to the three
33 /// places that must agree: the validator, the tool's JSON schema, and its
34 /// model-visible description.
35 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
36 pub struct UserInputLimits {
37 pub max_questions: usize,
38 pub max_options: usize,
39 }
40
41 impl Default for UserInputLimits {
42 fn default() -> Self {
43 Self {
44 max_questions: DEFAULT_MAX_QUESTIONS,
45 max_options: DEFAULT_MAX_OPTIONS,
46 }
47 }
48 }
49
50 impl UserInputLimits {
51 /// Resolve raw `[tools]` values, clamping each into its supported range.
52 /// An out-of-range value is honoured as far as it can be rather than
53 /// failing the load, and says so once with the key and the value used.
54 #[must_use]
55 pub fn from_config_values(max_questions: Option<u32>, max_options: Option<u32>) -> Self {
56 Self {
57 max_questions: clamp_with_warn(
58 max_questions,
59 DEFAULT_MAX_QUESTIONS,
60 MIN_CONFIGURABLE_QUESTIONS,
61 MAX_CONFIGURABLE_QUESTIONS,
62 MAX_QUESTIONS_KEY,
63 ),
64 max_options: clamp_with_warn(
65 max_options,
66 DEFAULT_MAX_OPTIONS,
67 MIN_CONFIGURABLE_OPTIONS,
68 MAX_CONFIGURABLE_OPTIONS,
69 MAX_OPTIONS_KEY,
70 ),
71 }
72 }
73 }
74
75 fn clamp_with_warn(raw: Option<u32>, default: usize, min: usize, max: usize, key: &str) -> usize {
76 let Some(raw) = raw else {
77 return default;
78 };
79 let raw = raw as usize;
80 let clamped = raw.clamp(min, max);
81 if clamped != raw {
82 tracing::warn!(
83 "`{key}` = {raw} is outside the supported range {min}..={max}; using {clamped}"
84 );
85 }
86 clamped
87 }
88
89 #[derive(Debug, Clone, Serialize, Deserialize)]
90 pub struct UserInputOption {
91 pub label: String,
92 pub description: String,
93 }
94
95 #[derive(Debug, Clone, Serialize, Deserialize)]
96 pub struct UserInputQuestion {
97 pub header: String,
98 pub id: String,
99 pub question: String,
100 pub options: Vec<UserInputOption>,
101 /// When `true`, the modal offers a free-text "Other" response in addition
102 /// to the fixed options. Defaults to `false` for backwards compatibility
103 /// (older payloads omitting the field get the previous behavior).
104 #[serde(default)]
105 pub allow_free_text: bool,
106 /// When `true`, the user may select more than one option before confirming.
107 #[serde(default)]
108 pub multi_select: bool,
109 }
110
111 #[derive(Debug, Clone, Serialize, Deserialize)]
112 pub struct UserInputRequest {
113 pub questions: Vec<UserInputQuestion>,
114 }
115
116 impl UserInputRequest {
117 /// Parse and validate against the built-in default limits. Call sites that
118 /// hold a session's resolved config use [`Self::from_value_with_limits`].
119 pub fn from_value(value: &Value) -> Result<Self, ToolError> {
120 Self::from_value_with_limits(value, UserInputLimits::default())
121 }
122
123 pub fn from_value_with_limits(
124 value: &Value,
125 limits: UserInputLimits,
126 ) -> Result<Self, ToolError> {
127 let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
128 ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
129 })?;
130 request.validate_with_limits(limits)?;
131 Ok(request)
132 }
133
134 pub fn validate(&self) -> Result<(), ToolError> {
135 self.validate_with_limits(UserInputLimits::default())
136 }
137
138 pub fn validate_with_limits(&self, limits: UserInputLimits) -> Result<(), ToolError> {
139 if self.questions.is_empty() {
140 return Err(ToolError::invalid_input(
141 "request_user_input.questions must be non-empty",
142 ));
143 }
144 if self.questions.len() > limits.max_questions {
145 // Name the ceiling *and* where to raise it: a rejection the model
146 // cannot act on just burns another turn (#5949).
147 return Err(ToolError::invalid_input(format!(
148 "request_user_input.questions must contain 1 to {max} items (got {got}); \
149 raise the ceiling with `{MAX_QUESTIONS_KEY}` in config.toml \
150 (supported range {MIN_CONFIGURABLE_QUESTIONS}..={MAX_CONFIGURABLE_QUESTIONS})",
151 max = limits.max_questions,
152 got = self.questions.len(),
153 )));
154 }
155 for q in &self.questions {
156 if q.header.trim().is_empty() {
157 return Err(ToolError::invalid_input(
158 "request_user_input.questions.header cannot be empty",
159 ));
160 }
161 if q.id.trim().is_empty() {
162 return Err(ToolError::invalid_input(
163 "request_user_input.questions.id cannot be empty",
164 ));
165 }
166 if q.question.trim().is_empty() {
167 return Err(ToolError::invalid_input(
168 "request_user_input.questions.question cannot be empty",
169 ));
170 }
171 if q.options.len() < MIN_CONFIGURABLE_OPTIONS || q.options.len() > limits.max_options {
172 return Err(ToolError::invalid_input(format!(
173 "request_user_input.questions.options must contain \
174 {MIN_CONFIGURABLE_OPTIONS} to {max} items (got {got}); \
175 raise the ceiling with `{MAX_OPTIONS_KEY}` in config.toml \
176 (supported range {MIN_CONFIGURABLE_OPTIONS}..={MAX_CONFIGURABLE_OPTIONS})",
177 max = limits.max_options,
178 got = q.options.len(),
179 )));
180 }
181 for opt in &q.options {
182 if opt.label.trim().is_empty() {
183 return Err(ToolError::invalid_input(
184 "request_user_input option label cannot be empty",
185 ));
186 }
187 if opt.description.trim().is_empty() {
188 return Err(ToolError::invalid_input(
189 "request_user_input option description cannot be empty",
190 ));
191 }
192 }
193 }
194 Ok(())
195 }
196 }
197
198 #[derive(Debug, Clone, Serialize, Deserialize)]
199 pub struct UserInputAnswer {
200 pub id: String,
201 pub label: String,
202 pub value: String,
203 }
204
205 #[derive(Debug, Clone, Serialize, Deserialize)]
206 pub struct UserInputResponse {
207 pub answers: Vec<UserInputAnswer>,
208 }
209
210 pub struct RequestUserInputTool {
211 limits: UserInputLimits,
212 /// Rendered once at construction: `description` hands out a borrow, and the
213 /// effective ceiling only changes when the registry is rebuilt.
214 description: String,
215 }
216
217 impl Default for RequestUserInputTool {
218 fn default() -> Self {
219 Self::new(UserInputLimits::default())
220 }
221 }
222
223 impl RequestUserInputTool {
224 #[must_use]
225 pub fn new(limits: UserInputLimits) -> Self {
226 Self {
227 description: format!(
228 "Ask the user 1-{} short questions with selectable options and return their \
229 selections. Reach for this when a decision is genuinely the user's to make and guessing \
230 would be costly or wrong: ambiguous scope, an irreversible or expensive choice, a missing \
231 preference, or a fork the user should own. Do not use it for facts you can find in the \
232 workspace — investigate those instead. The call blocks until the user answers.",
233 limits.max_questions
234 ),
235 limits,
236 }
237 }
238 }
239
240 #[async_trait]
241 impl ToolSpec for RequestUserInputTool {
242 fn name(&self) -> &'static str {
243 "request_user_input"
244 }
245
246 fn description(&self) -> &str {
247 &self.description
248 }
249
250 fn input_schema(&self) -> Value {
251 json!({
252 "type": "object",
253 "properties": {
254 "questions": {
255 "type": "array",
256 "items": {
257 "type": "object",
258 "properties": {
259 "header": { "type": "string" },
260 "id": { "type": "string" },
261 "question": { "type": "string" },
262 "options": {
263 "type": "array",
264 "items": {
265 "type": "object",
266 "properties": {
267 "label": { "type": "string" },
268 "description": { "type": "string" }
269 },
270 "required": ["label", "description"]
271 },
272 "minItems": MIN_CONFIGURABLE_OPTIONS,
273 "maxItems": self.limits.max_options
274 },
275 "allow_free_text": {
276 "type": "boolean",
277 "description": "When true, also offer a free-text 'Other' response. Defaults to false.",
278 "default": false
279 },
280 "multi_select": {
281 "type": "boolean",
282 "description": "When true, allow selecting more than one option. Defaults to false.",
283 "default": false
284 }
285 },
286 "required": ["header", "id", "question", "options"]
287 },
288 "minItems": 1,
289 "maxItems": self.limits.max_questions
290 }
291 },
292 "required": ["questions"]
293 })
294 }
295
296 fn capabilities(&self) -> Vec<ToolCapability> {
297 vec![ToolCapability::ReadOnly]
298 }
299
300 fn approval_requirement(&self) -> ApprovalRequirement {
301 ApprovalRequirement::Auto
302 }
303
304 async fn execute(
305 &self,
306 _input: Value,
307 _context: &ToolContext,
308 ) -> Result<ToolResult, ToolError> {
309 Err(ToolError::execution_failed(
310 "request_user_input must be handled by the engine",
311 ))
312 }
313 }
314
315 #[cfg(test)]
316 mod tests {
317 use super::*;
318
319 #[test]
320 fn validates_request_shape() {
321 let request = UserInputRequest {
322 questions: vec![UserInputQuestion {
323 header: "Pick".to_string(),
324 id: "choice".to_string(),
325 question: "Which option?".to_string(),
326 options: vec![
327 UserInputOption {
328 label: "A".to_string(),
329 description: "Option A".to_string(),
330 },
331 UserInputOption {
332 label: "B".to_string(),
333 description: "Option B".to_string(),
334 },
335 ],
336 allow_free_text: false,
337 multi_select: false,
338 }],
339 };
340 assert!(request.validate().is_ok());
341 }
342
343 #[test]
344 fn from_value_accepts_four_options_and_flags() {
345 // Mirrors the json!-literal style used in tools/subagent/tests.rs and
346 // exercises the schema-loosening from issue #3102: 4 options (was capped
347 // at 3) plus the new allow_free_text / multi_select flags.
348 let input = json!({
349 "questions": [{
350 "header": "Scope",
351 "id": "scope",
352 "question": "Which surfaces should this change affect?",
353 "options": [
354 { "label": "TUI", "description": "Visible modal flow only" },
355 { "label": "Headless", "description": "Protocol event only" },
356 { "label": "All surfaces", "description": "TUI and headless" },
357 { "label": "CLI", "description": "Command-line surface" }
358 ],
359 "allow_free_text": true,
360 "multi_select": true
361 }]
362 });
363 let request = UserInputRequest::from_value(&input).expect("4 options + flags parse");
364 assert_eq!(request.questions.len(), 1);
365 assert_eq!(request.questions[0].options.len(), 4);
366 assert!(request.questions[0].allow_free_text);
367 assert!(request.questions[0].multi_select);
368 }
369
370 #[test]
371 fn from_value_defaults_flags_when_omitted() {
372 // Backwards compatibility: a legacy payload omitting the new boolean
373 // fields must still parse, defaulting both to false.
374 let input = json!({
375 "questions": [{
376 "header": "Pick",
377 "id": "choice",
378 "question": "Which?",
379 "options": [
380 { "label": "A", "description": "a" },
381 { "label": "B", "description": "b" }
382 ]
383 }]
384 });
385 let request = UserInputRequest::from_value(&input).expect("legacy payload parses");
386 assert!(!request.questions[0].allow_free_text);
387 assert!(!request.questions[0].multi_select);
388 }
389
390 #[test]
391 fn rejects_five_options() {
392 let input = json!({
393 "questions": [{
394 "header": "Pick",
395 "id": "choice",
396 "question": "Which?",
397 "options": [
398 { "label": "A", "description": "a" },
399 { "label": "B", "description": "b" },
400 { "label": "C", "description": "c" },
401 { "label": "D", "description": "d" },
402 { "label": "E", "description": "e" }
403 ]
404 }]
405 });
406 let err = UserInputRequest::from_value(&input).expect_err("5 options must fail");
407 assert!(err.to_string().contains("2 to 4 items"));
408 }
409
410 fn yes_no_question(header: &str, id: &str) -> UserInputQuestion {
411 UserInputQuestion {
412 header: header.to_string(),
413 id: id.to_string(),
414 question: "?".to_string(),
415 options: vec![
416 UserInputOption {
417 label: "A".to_string(),
418 description: "A".to_string(),
419 },
420 UserInputOption {
421 label: "B".to_string(),
422 description: "B".to_string(),
423 },
424 ],
425 allow_free_text: false,
426 multi_select: false,
427 }
428 }
429
430 fn questions(count: usize) -> UserInputRequest {
431 UserInputRequest {
432 questions: (1..=count)
433 .map(|i| yes_no_question(&format!("Q{i}"), &format!("q{i}")))
434 .collect(),
435 }
436 }
437
438 #[test]
439 fn default_limits_are_six_questions_and_four_options() {
440 let limits = UserInputLimits::default();
441 assert_eq!(limits.max_questions, 6);
442 assert_eq!(limits.max_options, 4);
443 // An empty `[tools]` table resolves to the same ceilings.
444 assert_eq!(UserInputLimits::from_config_values(None, None), limits);
445 let tool = RequestUserInputTool::default();
446 let description = tool.description();
447 assert!(description.contains("Ask the user 1-6 short questions"));
448 assert!(description.contains("blocks until the user answers"));
449 }
450
451 #[test]
452 fn accepts_six_questions_by_default() {
453 assert!(questions(6).validate().is_ok());
454 }
455
456 #[test]
457 fn rejects_seven_questions_and_names_the_config_key() {
458 let err = questions(7)
459 .validate()
460 .expect_err("7 questions exceeds the default ceiling of 6");
461 let msg = err.to_string();
462 assert!(msg.contains("1 to 6 items"), "{msg}");
463 assert!(msg.contains("got 7"), "{msg}");
464 assert!(msg.contains("[tools] user_input_max_questions"), "{msg}");
465 assert!(msg.contains("config.toml"), "{msg}");
466 }
467
468 #[test]
469 fn rejected_option_count_names_the_config_key() {
470 let input = json!({
471 "questions": [{
472 "header": "Pick",
473 "id": "choice",
474 "question": "Which?",
475 "options": [
476 { "label": "A", "description": "a" },
477 { "label": "B", "description": "b" },
478 { "label": "C", "description": "c" },
479 { "label": "D", "description": "d" },
480 { "label": "E", "description": "e" }
481 ]
482 }]
483 });
484 let msg = UserInputRequest::from_value(&input)
485 .expect_err("5 options must fail")
486 .to_string();
487 assert!(msg.contains("[tools] user_input_max_options"), "{msg}");
488 }
489
490 #[test]
491 fn configured_limits_widen_and_narrow_the_validator() {
492 let wide = UserInputLimits::from_config_values(Some(9), Some(6));
493 assert!(questions(9).validate_with_limits(wide).is_ok());
494
495 let narrow = UserInputLimits::from_config_values(Some(2), None);
496 let msg = questions(3)
497 .validate_with_limits(narrow)
498 .expect_err("3 questions exceeds a configured ceiling of 2")
499 .to_string();
500 assert!(msg.contains("1 to 2 items"), "{msg}");
501 }
502
503 #[test]
504 fn out_of_range_config_values_clamp() {
505 // Below the floor and far above the ceiling both clamp instead of
506 // failing the config load.
507 let low = UserInputLimits::from_config_values(Some(0), Some(0));
508 assert_eq!(low.max_questions, MIN_CONFIGURABLE_QUESTIONS);
509 assert_eq!(low.max_options, MIN_CONFIGURABLE_OPTIONS);
510
511 let high = UserInputLimits::from_config_values(Some(50), Some(50));
512 assert_eq!(high.max_questions, MAX_CONFIGURABLE_QUESTIONS);
513 assert_eq!(high.max_options, MAX_CONFIGURABLE_OPTIONS);
514 }
515
516 #[test]
517 fn schema_and_description_reflect_configured_limits() {
518 let tool = RequestUserInputTool::new(UserInputLimits::from_config_values(Some(8), Some(5)));
519 let description = tool.description();
520 assert!(description.contains("Ask the user 1-8 short questions"));
521 assert!(description.contains("blocks until the user answers"));
522 let schema = tool.input_schema();
523 let questions = &schema["properties"]["questions"];
524 assert_eq!(questions["minItems"], json!(1));
525 assert_eq!(questions["maxItems"], json!(8));
526 let options = &questions["items"]["properties"]["options"];
527 assert_eq!(options["minItems"], json!(2));
528 assert_eq!(options["maxItems"], json!(5));
529
530 // Defaults land on the documented 6 / 4 pair.
531 let default_schema = RequestUserInputTool::default().input_schema();
532 assert_eq!(
533 default_schema["properties"]["questions"]["maxItems"],
534 json!(6)
535 );
536 assert_eq!(
537 default_schema["properties"]["questions"]["items"]["properties"]["options"]["maxItems"],
538 json!(4)
539 );
540 }
541
542 #[test]
543 fn rejects_too_many_questions() {
544 // Seven is one past the default ceiling; four now parses (#5949).
545 assert!(questions(4).validate().is_ok());
546 assert!(questions(7).validate().is_err());
547 }
548 }
549
549 lines RUST