返回 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 #[cfg(test)]
120 pub fn from_value(value: &Value) -> Result<Self, ToolError> {
121 Self::from_value_with_limits(value, UserInputLimits::default())
122 }
123
124 pub fn from_value_with_limits(
125 value: &Value,
126 limits: UserInputLimits,
127 ) -> Result<Self, ToolError> {
128 let request: UserInputRequest = serde_json::from_value(value.clone()).map_err(|e| {
129 ToolError::invalid_input(format!("Invalid request_user_input payload: {e}"))
130 })?;
131 request.validate_with_limits(limits)?;
132 Ok(request)
133 }
134
135 #[cfg(test)]
136 pub fn validate(&self) -> Result<(), ToolError> {
137 self.validate_with_limits(UserInputLimits::default())
138 }
139
140 pub fn validate_with_limits(&self, limits: UserInputLimits) -> Result<(), ToolError> {
141 if self.questions.is_empty() {
142 return Err(ToolError::invalid_input(
143 "request_user_input.questions must be non-empty",
144 ));
145 }
146 if self.questions.len() > limits.max_questions {
147 // Name the ceiling *and* where to raise it: a rejection the model
148 // cannot act on just burns another turn (#5949).
149 return Err(ToolError::invalid_input(format!(
150 "request_user_input.questions must contain 1 to {max} items (got {got}); \
151 raise the ceiling with `{MAX_QUESTIONS_KEY}` in config.toml \
152 (supported range {MIN_CONFIGURABLE_QUESTIONS}..={MAX_CONFIGURABLE_QUESTIONS})",
153 max = limits.max_questions,
154 got = self.questions.len(),
155 )));
156 }
157 for q in &self.questions {
158 if q.header.trim().is_empty() {
159 return Err(ToolError::invalid_input(
160 "request_user_input.questions.header cannot be empty",
161 ));
162 }
163 if q.id.trim().is_empty() {
164 return Err(ToolError::invalid_input(
165 "request_user_input.questions.id cannot be empty",
166 ));
167 }
168 if q.question.trim().is_empty() {
169 return Err(ToolError::invalid_input(
170 "request_user_input.questions.question cannot be empty",
171 ));
172 }
173 if q.options.len() < MIN_CONFIGURABLE_OPTIONS || q.options.len() > limits.max_options {
174 return Err(ToolError::invalid_input(format!(
175 "request_user_input.questions.options must contain \
176 {MIN_CONFIGURABLE_OPTIONS} to {max} items (got {got}); \
177 raise the ceiling with `{MAX_OPTIONS_KEY}` in config.toml \
178 (supported range {MIN_CONFIGURABLE_OPTIONS}..={MAX_CONFIGURABLE_OPTIONS})",
179 max = limits.max_options,
180 got = q.options.len(),
181 )));
182 }
183 for opt in &q.options {
184 if opt.label.trim().is_empty() {
185 return Err(ToolError::invalid_input(
186 "request_user_input option label cannot be empty",
187 ));
188 }
189 if opt.description.trim().is_empty() {
190 return Err(ToolError::invalid_input(
191 "request_user_input option description cannot be empty",
192 ));
193 }
194 }
195 }
196 Ok(())
197 }
198 }
199
200 #[derive(Debug, Clone, Serialize, Deserialize)]
201 pub struct UserInputAnswer {
202 pub id: String,
203 pub label: String,
204 pub value: String,
205 }
206
207 #[derive(Debug, Clone, Serialize, Deserialize)]
208 pub struct UserInputResponse {
209 pub answers: Vec<UserInputAnswer>,
210 }
211
212 pub struct RequestUserInputTool {
213 limits: UserInputLimits,
214 /// Rendered once at construction: `description` hands out a borrow, and the
215 /// effective ceiling only changes when the registry is rebuilt.
216 description: String,
217 }
218
219 impl Default for RequestUserInputTool {
220 fn default() -> Self {
221 Self::new(UserInputLimits::default())
222 }
223 }
224
225 impl RequestUserInputTool {
226 #[must_use]
227 pub fn new(limits: UserInputLimits) -> Self {
228 Self {
229 description: format!(
230 "Ask the user 1-{} short questions with selectable options and return their \
231 selections. Reach for this when a decision is genuinely the user's to make and guessing \
232 would be costly or wrong: ambiguous scope, an irreversible or expensive choice, a missing \
233 preference, or a fork the user should own. Do not use it for facts you can find in the \
234 workspace — investigate those instead. The call blocks until the user answers.",
235 limits.max_questions
236 ),
237 limits,
238 }
239 }
240 }
241
242 #[async_trait]
243 impl ToolSpec for RequestUserInputTool {
244 fn name(&self) -> &'static str {
245 "request_user_input"
246 }
247
248 fn description(&self) -> &str {
249 &self.description
250 }
251
252 fn input_schema(&self) -> Value {
253 json!({
254 "type": "object",
255 "properties": {
256 "questions": {
257 "type": "array",
258 "items": {
259 "type": "object",
260 "properties": {
261 "header": { "type": "string" },
262 "id": { "type": "string" },
263 "question": { "type": "string" },
264 "options": {
265 "type": "array",
266 "items": {
267 "type": "object",
268 "properties": {
269 "label": { "type": "string" },
270 "description": { "type": "string" }
271 },
272 "required": ["label", "description"]
273 },
274 "minItems": MIN_CONFIGURABLE_OPTIONS,
275 "maxItems": self.limits.max_options
276 },
277 "allow_free_text": {
278 "type": "boolean",
279 "description": "When true, also offer a free-text 'Other' response. Defaults to false.",
280 "default": false
281 },
282 "multi_select": {
283 "type": "boolean",
284 "description": "When true, allow selecting more than one option. Defaults to false.",
285 "default": false
286 }
287 },
288 "required": ["header", "id", "question", "options"]
289 },
290 "minItems": 1,
291 "maxItems": self.limits.max_questions
292 }
293 },
294 "required": ["questions"]
295 })
296 }
297
298 fn capabilities(&self) -> Vec<ToolCapability> {
299 vec![ToolCapability::ReadOnly]
300 }
301
302 fn approval_requirement(&self) -> ApprovalRequirement {
303 ApprovalRequirement::Auto
304 }
305
306 async fn execute(
307 &self,
308 _input: Value,
309 _context: &ToolContext,
310 ) -> Result<ToolResult, ToolError> {
311 Err(ToolError::execution_failed(
312 "request_user_input must be handled by the engine",
313 ))
314 }
315 }
316
317 #[cfg(test)]
318 mod tests {
319 use super::*;
320
321 #[test]
322 fn validates_request_shape() {
323 let request = UserInputRequest {
324 questions: vec![UserInputQuestion {
325 header: "Pick".to_string(),
326 id: "choice".to_string(),
327 question: "Which option?".to_string(),
328 options: vec![
329 UserInputOption {
330 label: "A".to_string(),
331 description: "Option A".to_string(),
332 },
333 UserInputOption {
334 label: "B".to_string(),
335 description: "Option B".to_string(),
336 },
337 ],
338 allow_free_text: false,
339 multi_select: false,
340 }],
341 };
342 assert!(request.validate().is_ok());
343 }
344
345 #[test]
346 fn from_value_accepts_four_options_and_flags() {
347 // Mirrors the json!-literal style used in tools/subagent/tests.rs and
348 // exercises the schema-loosening from issue #3102: 4 options (was capped
349 // at 3) plus the new allow_free_text / multi_select flags.
350 let input = json!({
351 "questions": [{
352 "header": "Scope",
353 "id": "scope",
354 "question": "Which surfaces should this change affect?",
355 "options": [
356 { "label": "TUI", "description": "Visible modal flow only" },
357 { "label": "Headless", "description": "Protocol event only" },
358 { "label": "All surfaces", "description": "TUI and headless" },
359 { "label": "CLI", "description": "Command-line surface" }
360 ],
361 "allow_free_text": true,
362 "multi_select": true
363 }]
364 });
365 let request = UserInputRequest::from_value(&input).expect("4 options + flags parse");
366 assert_eq!(request.questions.len(), 1);
367 assert_eq!(request.questions[0].options.len(), 4);
368 assert!(request.questions[0].allow_free_text);
369 assert!(request.questions[0].multi_select);
370 }
371
372 #[test]
373 fn from_value_defaults_flags_when_omitted() {
374 // Backwards compatibility: a legacy payload omitting the new boolean
375 // fields must still parse, defaulting both to false.
376 let input = json!({
377 "questions": [{
378 "header": "Pick",
379 "id": "choice",
380 "question": "Which?",
381 "options": [
382 { "label": "A", "description": "a" },
383 { "label": "B", "description": "b" }
384 ]
385 }]
386 });
387 let request = UserInputRequest::from_value(&input).expect("legacy payload parses");
388 assert!(!request.questions[0].allow_free_text);
389 assert!(!request.questions[0].multi_select);
390 }
391
392 #[test]
393 fn rejects_five_options() {
394 let input = json!({
395 "questions": [{
396 "header": "Pick",
397 "id": "choice",
398 "question": "Which?",
399 "options": [
400 { "label": "A", "description": "a" },
401 { "label": "B", "description": "b" },
402 { "label": "C", "description": "c" },
403 { "label": "D", "description": "d" },
404 { "label": "E", "description": "e" }
405 ]
406 }]
407 });
408 let err = UserInputRequest::from_value(&input).expect_err("5 options must fail");
409 assert!(err.to_string().contains("2 to 4 items"));
410 }
411
412 fn yes_no_question(header: &str, id: &str) -> UserInputQuestion {
413 UserInputQuestion {
414 header: header.to_string(),
415 id: id.to_string(),
416 question: "?".to_string(),
417 options: vec![
418 UserInputOption {
419 label: "A".to_string(),
420 description: "A".to_string(),
421 },
422 UserInputOption {
423 label: "B".to_string(),
424 description: "B".to_string(),
425 },
426 ],
427 allow_free_text: false,
428 multi_select: false,
429 }
430 }
431
432 fn questions(count: usize) -> UserInputRequest {
433 UserInputRequest {
434 questions: (1..=count)
435 .map(|i| yes_no_question(&format!("Q{i}"), &format!("q{i}")))
436 .collect(),
437 }
438 }
439
440 #[test]
441 fn default_limits_are_six_questions_and_four_options() {
442 let limits = UserInputLimits::default();
443 assert_eq!(limits.max_questions, 6);
444 assert_eq!(limits.max_options, 4);
445 // An empty `[tools]` table resolves to the same ceilings.
446 assert_eq!(UserInputLimits::from_config_values(None, None), limits);
447 let tool = RequestUserInputTool::default();
448 let description = tool.description();
449 assert!(description.contains("Ask the user 1-6 short questions"));
450 assert!(description.contains("blocks until the user answers"));
451 }
452
453 #[test]
454 fn accepts_six_questions_by_default() {
455 assert!(questions(6).validate().is_ok());
456 }
457
458 #[test]
459 fn rejects_seven_questions_and_names_the_config_key() {
460 let err = questions(7)
461 .validate()
462 .expect_err("7 questions exceeds the default ceiling of 6");
463 let msg = err.to_string();
464 assert!(msg.contains("1 to 6 items"), "{msg}");
465 assert!(msg.contains("got 7"), "{msg}");
466 assert!(msg.contains("[tools] user_input_max_questions"), "{msg}");
467 assert!(msg.contains("config.toml"), "{msg}");
468 }
469
470 #[test]
471 fn rejected_option_count_names_the_config_key() {
472 let input = json!({
473 "questions": [{
474 "header": "Pick",
475 "id": "choice",
476 "question": "Which?",
477 "options": [
478 { "label": "A", "description": "a" },
479 { "label": "B", "description": "b" },
480 { "label": "C", "description": "c" },
481 { "label": "D", "description": "d" },
482 { "label": "E", "description": "e" }
483 ]
484 }]
485 });
486 let msg = UserInputRequest::from_value(&input)
487 .expect_err("5 options must fail")
488 .to_string();
489 assert!(msg.contains("[tools] user_input_max_options"), "{msg}");
490 }
491
492 #[test]
493 fn configured_limits_widen_and_narrow_the_validator() {
494 let wide = UserInputLimits::from_config_values(Some(9), Some(6));
495 assert!(questions(9).validate_with_limits(wide).is_ok());
496
497 let narrow = UserInputLimits::from_config_values(Some(2), None);
498 let msg = questions(3)
499 .validate_with_limits(narrow)
500 .expect_err("3 questions exceeds a configured ceiling of 2")
501 .to_string();
502 assert!(msg.contains("1 to 2 items"), "{msg}");
503 }
504
505 #[test]
506 fn out_of_range_config_values_clamp() {
507 // Below the floor and far above the ceiling both clamp instead of
508 // failing the config load.
509 let low = UserInputLimits::from_config_values(Some(0), Some(0));
510 assert_eq!(low.max_questions, MIN_CONFIGURABLE_QUESTIONS);
511 assert_eq!(low.max_options, MIN_CONFIGURABLE_OPTIONS);
512
513 let high = UserInputLimits::from_config_values(Some(50), Some(50));
514 assert_eq!(high.max_questions, MAX_CONFIGURABLE_QUESTIONS);
515 assert_eq!(high.max_options, MAX_CONFIGURABLE_OPTIONS);
516 }
517
518 #[test]
519 fn schema_and_description_reflect_configured_limits() {
520 let tool = RequestUserInputTool::new(UserInputLimits::from_config_values(Some(8), Some(5)));
521 let description = tool.description();
522 assert!(description.contains("Ask the user 1-8 short questions"));
523 assert!(description.contains("blocks until the user answers"));
524 let schema = tool.input_schema();
525 let questions = &schema["properties"]["questions"];
526 assert_eq!(questions["minItems"], json!(1));
527 assert_eq!(questions["maxItems"], json!(8));
528 let options = &questions["items"]["properties"]["options"];
529 assert_eq!(options["minItems"], json!(2));
530 assert_eq!(options["maxItems"], json!(5));
531
532 // Defaults land on the documented 6 / 4 pair.
533 let default_schema = RequestUserInputTool::default().input_schema();
534 assert_eq!(
535 default_schema["properties"]["questions"]["maxItems"],
536 json!(6)
537 );
538 assert_eq!(
539 default_schema["properties"]["questions"]["items"]["properties"]["options"]["maxItems"],
540 json!(4)
541 );
542 }
543
544 #[test]
545 fn rejects_too_many_questions() {
546 // Seven is one past the default ceiling; four now parses (#5949).
547 assert!(questions(4).validate().is_ok());
548 assert!(questions(7).validate().is_err());
549 }
550 }
551
551 lines RUST