1use crate::error::RuntimeError;
2use crate::form::{CompositeForm, FormAnswer, FormKind, FormQuestion, PendingForm};
3use crate::tool::{ApprovalLevel, BoxFut, Tier, Tool, ToolArgs, ToolCtx, ToolResult};
4use crate::value::Value;
5
6pub struct FormAsk;
7
8impl Tool for FormAsk {
9 fn name(&self) -> &str {
10 "form.ask"
11 }
12
13 fn tier(&self) -> Tier {
14 Tier::Zero
15 }
16
17 fn approval_level(&self, _args: &ToolArgs, _ctx: &ToolCtx) -> ApprovalLevel {
18 ApprovalLevel::Auto
19 }
20
21 fn description(&self) -> Option<&str> {
22 Some(
23 "Ask the user a structured question through a form modal. Pass `kind`
24 plus fields required for that kind:
25 \
26 confirm { kind:\"confirm\", prompt }
27 single_select { kind:\"single_select\", prompt, options[] }
28 multi_select { kind:\"multi_select\", prompt, options[], min?, max? }
29 text { kind:\"text\", prompt, placeholder?, multiline? }
30 \
31 For several independent answers, make one `form.ask` call with a `questions`
32 list. Each question has an `id`, `kind`, and the fields for that kind.
33 The UI keeps all answers as a draft and asks for one final Yes/No confirmation;
34 do not make multiple calls expecting the UI to merge them.
35 \
36 Returns a struct { kind, ... } where kind is one of \
37 confirmed | selected | multi_selected | text_entered | cancelled.",
38 )
39 }
40
41 fn input_schema(&self) -> serde_json::Value {
42 serde_json::json!({
43 "type": "object",
44 "properties": {
45 "kind": {"type": "string"},
46 "prompt": {"type": "string"},
47 "options": {"type": "array", "items": {"type": "string"}},
48 "min": {"type": "integer"},
49 "max": {"type": "integer"},
50 "placeholder": {"type": "string"},
51 "multiline": {"type": "boolean"},
52 "questions": {
53 "type": "array",
54 "minItems": 1,
55 "items": {
56 "type": "object",
57 "properties": {
58 "id": {"type": "string"},
59 "kind": {"type": "string"},
60 "prompt": {"type": "string"},
61 "options": {"type": "array", "items": {"type": "string"}},
62 "min": {"type": "integer"},
63 "max": {"type": "integer"},
64 "placeholder": {"type": "string"},
65 "multiline": {"type": "boolean"}
66 },
67 "required": ["id", "kind", "prompt"]
68 }
69 }
70 },
71 "oneOf": [
72 {"required": ["kind", "prompt"], "not": {"required": ["questions"]}},
73 {"required": ["questions"], "not": {"anyOf": [{"required": ["kind"]}, {"required": ["prompt"]}]}}
74 ]
75 })
76 }
77
78 fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
79 Box::pin(async move {
80 let (form, kind, composite) = parse_form_request(&args)?;
81 if let Some(resolver) = ctx.prompt_resolver.clone() {
86 let id = crate::rendezvous::PromptId::now();
87 let payload = if composite {
88 serde_json::to_value(&form).unwrap_or(serde_json::Value::Null)
89 } else {
90 serde_json::to_value(&kind).unwrap_or(serde_json::Value::Null)
91 };
92 let timeout = std::time::Duration::from_secs(300);
93 let answer_json = crate::rendezvous::await_prompt_with_payload(
94 &resolver, id, "form_ask", payload, timeout,
95 )
96 .await?;
97 let submission = serde_json::from_value::<crate::form::FormSubmission>(answer_json)
98 .unwrap_or(crate::form::FormSubmission::Rejected);
99 return Ok(submission_to_value(&submission, composite));
100 }
101 let forms = ctx.forms.as_ref().ok_or_else(|| {
102 RuntimeError::ToolFailed(
103 "form.ask: no FormRegistry or PromptResolver attached".into(),
104 )
105 })?;
106 let run_id = ctx.flow_run_id.clone().ok_or_else(|| {
107 RuntimeError::ToolFailed("form.ask: no flow_run_id in ctx".into())
108 })?;
109 let form_id = uuid::Uuid::now_v7().to_string();
110 let pending = PendingForm {
111 form_id: form_id.clone(),
112 run_id,
113 tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
114 form,
115 kind,
116 emitted_at: chrono::Utc::now(),
117 };
118 let rx = forms.request(pending);
119 let submission =
120 await_local_submission(forms, form_id, rx, std::time::Duration::from_secs(300))
121 .await;
122 Ok(submission_to_value(&submission, composite))
123 })
124 }
125}
126
127async fn await_local_submission(
128 forms: &crate::session::FormRegistry,
129 form_id: String,
130 rx: tokio::sync::oneshot::Receiver<crate::form::FormSubmission>,
131 timeout: std::time::Duration,
132) -> crate::form::FormSubmission {
133 match tokio::time::timeout(timeout, rx).await {
134 Ok(Ok(submission)) => submission,
135 Ok(Err(_)) | Err(_) => {
136 forms.cancel(&form_id);
137 crate::form::FormSubmission::Rejected
138 }
139 }
140}
141
142fn submission_to_value(submission: &crate::form::FormSubmission, composite: bool) -> Value {
143 match submission {
144 crate::form::FormSubmission::Submitted { answers } if composite => Value::Struct(vec![
145 ("kind".into(), Value::Str("submitted".into())),
146 (
147 "answers".into(),
148 Value::List(answers.iter().map(answer_to_value).collect()),
149 ),
150 ]),
151 crate::form::FormSubmission::Submitted { answers } => {
152 answers.first().map(answer_to_value).unwrap_or_else(|| {
153 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
154 })
155 }
156 crate::form::FormSubmission::Rejected => {
157 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
158 }
159 }
160}
161
162fn parse_form_request(args: &ToolArgs) -> Result<(CompositeForm, FormKind, bool), RuntimeError> {
163 match (args.named("questions"), args.named("kind")) {
164 (Some(Value::List(items)), None) => {
165 if items.is_empty() {
166 return Err(RuntimeError::ToolFailed(
167 "form.ask: `questions` must be non-empty".into(),
168 ));
169 }
170 let mut questions = Vec::with_capacity(items.len());
171 for (index, item) in items.iter().enumerate() {
172 let Value::Struct(fields) = item else {
173 return Err(RuntimeError::TypeMismatch {
174 expected: "struct {id, kind, prompt, ...}".into(),
175 actual: item.kind_name().into(),
176 });
177 };
178 let get = |name: &str| {
179 fields
180 .iter()
181 .find(|(key, _)| key == name)
182 .map(|(_, value)| value)
183 };
184 let id = match get("id") {
185 Some(Value::Str(value)) if !value.is_empty() => value.clone(),
186 Some(value) => {
187 return Err(RuntimeError::TypeMismatch {
188 expected: "string".into(),
189 actual: value.kind_name().into(),
190 });
191 }
192 None => return Err(RuntimeError::MissingArg(format!("questions[{index}].id"))),
193 };
194 if questions
195 .iter()
196 .any(|question: &FormQuestion| question.id == id)
197 {
198 return Err(RuntimeError::ToolFailed(format!(
199 "form.ask: duplicate question id `{id}`"
200 )));
201 }
202 let named = ToolArgs {
203 positional: Vec::new(),
204 named: fields.clone(),
205 };
206 let kind = parse_form_kind(&named)?;
207 questions.push(FormQuestion { id, kind });
208 }
209 let first = questions[0].kind.clone();
210 Ok((CompositeForm { questions }, first, true))
211 }
212 (Some(value), _) => Err(RuntimeError::TypeMismatch {
213 expected: "list<struct>".into(),
214 actual: value.kind_name().into(),
215 }),
216 (None, _) => {
217 let kind = parse_form_kind(args)?;
218 Ok((
219 CompositeForm {
220 questions: vec![FormQuestion {
221 id: "question".into(),
222 kind: kind.clone(),
223 }],
224 },
225 kind,
226 false,
227 ))
228 }
229 }
230}
231
232fn parse_form_kind(args: &ToolArgs) -> Result<FormKind, RuntimeError> {
233 let kind = named_str(args, "kind")?;
234 let prompt = named_str(args, "prompt")?;
235 match kind.as_str() {
236 "confirm" => Ok(FormKind::Confirm { prompt }),
237 "single_select" => {
238 let options = named_string_list(args, "options")?;
239 if options.is_empty() {
240 return Err(RuntimeError::ToolFailed(
241 "form.ask(single_select): options must be non-empty".into(),
242 ));
243 }
244 Ok(FormKind::SingleSelect { prompt, options })
245 }
246 "multi_select" => {
247 let options = named_string_list(args, "options")?;
248 if options.is_empty() {
249 return Err(RuntimeError::ToolFailed(
250 "form.ask(multi_select): options must be non-empty".into(),
251 ));
252 }
253 let min = named_usize(args, "min")?;
254 let max = named_usize(args, "max")?;
255 if let (Some(m), Some(mx)) = (min, max)
256 && mx < m
257 {
258 return Err(RuntimeError::ToolFailed(
259 "form.ask(multi_select): max must be >= min".into(),
260 ));
261 }
262 Ok(FormKind::MultiSelect {
263 prompt,
264 options,
265 min,
266 max,
267 })
268 }
269 "text" => {
270 let placeholder = named_opt_str(args, "placeholder")?;
271 let multiline = matches!(args.named("multiline"), Some(Value::Bool(true)));
272 Ok(FormKind::Text {
273 prompt,
274 placeholder,
275 multiline,
276 })
277 }
278 other => Err(RuntimeError::ToolFailed(format!(
279 "form.ask: unknown kind `{other}` (expected confirm | single_select | multi_select | text)"
280 ))),
281 }
282}
283
284fn named_str(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
285 match args.named(name) {
286 Some(Value::Str(s)) => Ok(s.clone()),
287 Some(v) => Err(RuntimeError::TypeMismatch {
288 expected: "string".into(),
289 actual: v.kind_name().into(),
290 }),
291 None => Err(RuntimeError::MissingArg(name.into())),
292 }
293}
294
295fn named_opt_str(args: &ToolArgs, name: &str) -> Result<Option<String>, RuntimeError> {
296 match args.named(name) {
297 Some(Value::Str(s)) => Ok(Some(s.clone())),
298 Some(Value::Unit) | None => Ok(None),
299 Some(v) => Err(RuntimeError::TypeMismatch {
300 expected: "string".into(),
301 actual: v.kind_name().into(),
302 }),
303 }
304}
305
306fn named_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
307 match args.named(name) {
308 Some(Value::List(items)) => items
309 .iter()
310 .map(|v| match v {
311 Value::Str(s) => Ok(s.clone()),
312 other => Err(RuntimeError::TypeMismatch {
313 expected: "string".into(),
314 actual: other.kind_name().into(),
315 }),
316 })
317 .collect(),
318 Some(v) => Err(RuntimeError::TypeMismatch {
319 expected: "list<string>".into(),
320 actual: v.kind_name().into(),
321 }),
322 None => Err(RuntimeError::MissingArg(name.into())),
323 }
324}
325
326fn named_usize(args: &ToolArgs, name: &str) -> Result<Option<usize>, RuntimeError> {
327 match args.named(name) {
328 Some(Value::Int(i)) if *i >= 0 => Ok(Some(*i as usize)),
329 Some(Value::Int(_)) => Err(RuntimeError::ToolFailed(format!(
330 "form.ask: `{name}` must be non-negative"
331 ))),
332 Some(Value::Unit) | None => Ok(None),
333 Some(v) => Err(RuntimeError::TypeMismatch {
334 expected: "int".into(),
335 actual: v.kind_name().into(),
336 }),
337 }
338}
339
340fn answer_to_value(answer: &FormAnswer) -> Value {
341 match answer {
342 FormAnswer::Confirmed { value } => Value::Struct(vec![
343 ("kind".into(), Value::Str("confirmed".into())),
344 ("value".into(), Value::Bool(*value)),
345 ]),
346 FormAnswer::Selected { index, label } => Value::Struct(vec![
347 ("kind".into(), Value::Str("selected".into())),
348 ("index".into(), Value::Int(*index as i64)),
349 ("label".into(), Value::Str(label.clone())),
350 ]),
351 FormAnswer::MultiSelected { indices, labels } => Value::Struct(vec![
352 ("kind".into(), Value::Str("multi_selected".into())),
353 (
354 "indices".into(),
355 Value::List(indices.iter().map(|i| Value::Int(*i as i64)).collect()),
356 ),
357 (
358 "labels".into(),
359 Value::List(labels.iter().map(|s| Value::Str(s.clone())).collect()),
360 ),
361 ]),
362 FormAnswer::TextEntered { text } => Value::Struct(vec![
363 ("kind".into(), Value::Str("text_entered".into())),
364 ("text".into(), Value::Str(text.clone())),
365 ]),
366 FormAnswer::Cancelled => {
367 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
368 }
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375 use crate::form::FormKind;
376 use crate::tool::ToolArgs;
377
378 fn named(name: &str, v: Value) -> (String, Value) {
379 (name.into(), v)
380 }
381
382 #[tokio::test]
383 async fn local_form_timeout_cancels_pending_entry() {
384 let forms = crate::session::FormRegistry::new();
385 let _subscriber = forms.subscribe();
386 let form_id = "timed-out".to_string();
387 let form = crate::form::CompositeForm {
388 questions: vec![crate::form::FormQuestion {
389 id: "question".into(),
390 kind: FormKind::Confirm { prompt: "?".into() },
391 }],
392 };
393 let pending = PendingForm {
394 form_id: form_id.clone(),
395 run_id: crate::event::FlowRunId::now(),
396 tool_use_id: "tool".into(),
397 form,
398 kind: FormKind::Confirm { prompt: "?".into() },
399 emitted_at: chrono::Utc::now(),
400 };
401 let rx = forms.request(pending);
402 assert_eq!(
403 await_local_submission(&forms, form_id, rx, std::time::Duration::ZERO).await,
404 crate::form::FormSubmission::Rejected
405 );
406 assert!(forms.list_pending().is_empty());
407 }
408
409 #[test]
410 fn parse_composite_questions() {
411 let question = |id: &str, prompt: &str| {
412 Value::Struct(vec![
413 ("id".into(), Value::Str(id.into())),
414 ("kind".into(), Value::Str("text".into())),
415 ("prompt".into(), Value::Str(prompt.into())),
416 ])
417 };
418 let args = ToolArgs {
419 positional: vec![],
420 named: vec![(
421 "questions".into(),
422 Value::List(vec![question("name", "Name?"), question("team", "Team?")]),
423 )],
424 };
425 let (form, first, composite) = parse_form_request(&args).unwrap();
426 assert!(composite);
427 assert_eq!(form.questions.len(), 2);
428 assert_eq!(form.questions[0].id, "name");
429 assert_eq!(form.questions[1].id, "team");
430 assert!(matches!(first, FormKind::Text { .. }));
431 }
432
433 #[test]
434 fn parse_composite_questions_rejects_duplicate_ids() {
435 let question = |id: &str| {
436 Value::Struct(vec![
437 ("id".into(), Value::Str(id.into())),
438 ("kind".into(), Value::Str("confirm".into())),
439 ("prompt".into(), Value::Str("Continue?".into())),
440 ])
441 };
442 let args = ToolArgs {
443 positional: vec![],
444 named: vec![(
445 "questions".into(),
446 Value::List(vec![question("same"), question("same")]),
447 )],
448 };
449 let error = parse_form_request(&args).unwrap_err();
450 assert!(error.to_string().contains("duplicate question id"));
451 }
452
453 #[test]
454 fn parse_confirm_kind() {
455 let args = ToolArgs {
456 positional: vec![],
457 named: vec![
458 named("kind", Value::Str("confirm".into())),
459 named("prompt", Value::Str("sure?".into())),
460 ],
461 };
462 assert!(matches!(
463 parse_form_kind(&args).unwrap(),
464 FormKind::Confirm { .. }
465 ));
466 }
467
468 #[test]
469 fn parse_single_select_rejects_empty_options() {
470 let args = ToolArgs {
471 positional: vec![],
472 named: vec![
473 named("kind", Value::Str("single_select".into())),
474 named("prompt", Value::Str("pick".into())),
475 named("options", Value::List(vec![])),
476 ],
477 };
478 let err = parse_form_kind(&args).unwrap_err();
479 assert!(err.to_string().contains("non-empty"));
480 }
481
482 #[test]
483 fn parse_multi_select_validates_bounds() {
484 let args = ToolArgs {
485 positional: vec![],
486 named: vec![
487 named("kind", Value::Str("multi_select".into())),
488 named("prompt", Value::Str("tags".into())),
489 named(
490 "options",
491 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
492 ),
493 named("min", Value::Int(3)),
494 named("max", Value::Int(1)),
495 ],
496 };
497 let err = parse_form_kind(&args).unwrap_err();
498 assert!(err.to_string().contains("max must be >= min"));
499 }
500
501 #[test]
502 fn parse_text_defaults_multiline_to_false() {
503 let args = ToolArgs {
504 positional: vec![],
505 named: vec![
506 named("kind", Value::Str("text".into())),
507 named("prompt", Value::Str("name?".into())),
508 ],
509 };
510 match parse_form_kind(&args).unwrap() {
511 FormKind::Text { multiline, .. } => assert!(!multiline),
512 other => panic!("expected text, got {other:?}"),
513 }
514 }
515
516 #[test]
517 fn parse_unknown_kind_errors_with_hint() {
518 let args = ToolArgs {
519 positional: vec![],
520 named: vec![
521 named("kind", Value::Str("weird".into())),
522 named("prompt", Value::Str("?".into())),
523 ],
524 };
525 let err = parse_form_kind(&args).unwrap_err();
526 assert!(err.to_string().contains("weird"));
527 assert!(err.to_string().contains("confirm"));
528 }
529
530 #[test]
531 fn answer_confirmed_becomes_struct() {
532 let v = answer_to_value(&FormAnswer::Confirmed { value: true });
533 assert_eq!(v.field("kind").unwrap().kind_name(), "string");
534 assert!(matches!(v.field("value"), Some(Value::Bool(true))));
535 }
536
537 #[test]
538 fn answer_multi_selected_carries_indices_and_labels() {
539 let v = answer_to_value(&FormAnswer::MultiSelected {
540 indices: vec![0, 2],
541 labels: vec!["a".into(), "c".into()],
542 });
543 let indices = match v.field("indices").unwrap() {
544 Value::List(l) => l,
545 _ => panic!("expected list"),
546 };
547 assert_eq!(indices.len(), 2);
548 }
549
550 #[test]
551 fn answer_cancelled_is_kind_only_struct() {
552 let v = answer_to_value(&FormAnswer::Cancelled);
553 assert!(matches!(v.field("kind"), Some(Value::Str(s)) if s == "cancelled"));
554 assert!(v.field("value").is_none());
555 }
556}