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_expirable_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 .map_err(|error| {
99 RuntimeError::ToolFailed(format!(
100 "form.ask: invalid prompt submission: {error}"
101 ))
102 })?;
103 return Ok(submission_to_value(&submission, composite));
104 }
105 let forms = ctx.forms.as_ref().ok_or_else(|| {
106 RuntimeError::ToolFailed(
107 "form.ask: no FormRegistry or PromptResolver attached".into(),
108 )
109 })?;
110 let run_id = ctx.flow_run_id.clone().ok_or_else(|| {
111 RuntimeError::ToolFailed("form.ask: no flow_run_id in ctx".into())
112 })?;
113 let form_id = uuid::Uuid::now_v7().to_string();
114 let pending = PendingForm {
115 form_id: form_id.clone(),
116 run_id,
117 tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
118 form,
119 kind,
120 emitted_at: chrono::Utc::now(),
121 };
122 let rx = forms.request(pending);
123 let submission =
124 await_local_submission(forms, form_id, rx, std::time::Duration::from_secs(300))
125 .await;
126 Ok(submission_to_value(&submission, composite))
127 })
128 }
129}
130
131async fn await_local_submission(
132 forms: &crate::session::FormRegistry,
133 form_id: String,
134 mut rx: tokio::sync::oneshot::Receiver<crate::form::FormSubmission>,
135 timeout: std::time::Duration,
136) -> crate::form::FormSubmission {
137 match tokio::time::timeout(timeout, &mut rx).await {
138 Ok(Ok(submission)) => submission,
139 Ok(Err(_)) => {
140 forms.cancel(&form_id);
141 crate::form::FormSubmission::Rejected
142 }
143 Err(_) => {
144 if forms.expire(&form_id) {
145 crate::form::FormSubmission::Rejected
146 } else {
147 rx.await.unwrap_or(crate::form::FormSubmission::Rejected)
148 }
149 }
150 }
151}
152
153fn submission_to_value(submission: &crate::form::FormSubmission, composite: bool) -> Value {
154 match submission {
155 crate::form::FormSubmission::Submitted { answers } if composite => Value::Struct(vec![
156 ("kind".into(), Value::Str("submitted".into())),
157 (
158 "answers".into(),
159 Value::List(answers.iter().map(answer_to_value).collect()),
160 ),
161 ]),
162 crate::form::FormSubmission::Submitted { answers } => {
163 answers.first().map(answer_to_value).unwrap_or_else(|| {
164 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
165 })
166 }
167 crate::form::FormSubmission::Rejected => {
168 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
169 }
170 }
171}
172
173fn parse_form_request(args: &ToolArgs) -> Result<(CompositeForm, FormKind, bool), RuntimeError> {
174 match (args.named("questions"), args.named("kind")) {
175 (Some(Value::List(items)), None) => {
176 if items.is_empty() {
177 return Err(RuntimeError::ToolFailed(
178 "form.ask: `questions` must be non-empty".into(),
179 ));
180 }
181 let mut questions = Vec::with_capacity(items.len());
182 for (index, item) in items.iter().enumerate() {
183 let Value::Struct(fields) = item else {
184 return Err(RuntimeError::TypeMismatch {
185 expected: "struct {id, kind, prompt, ...}".into(),
186 actual: item.kind_name().into(),
187 });
188 };
189 let get = |name: &str| {
190 fields
191 .iter()
192 .find(|(key, _)| key == name)
193 .map(|(_, value)| value)
194 };
195 let id = match get("id") {
196 Some(Value::Str(value)) if !value.is_empty() => value.clone(),
197 Some(value) => {
198 return Err(RuntimeError::TypeMismatch {
199 expected: "string".into(),
200 actual: value.kind_name().into(),
201 });
202 }
203 None => return Err(RuntimeError::MissingArg(format!("questions[{index}].id"))),
204 };
205 if questions
206 .iter()
207 .any(|question: &FormQuestion| question.id == id)
208 {
209 return Err(RuntimeError::ToolFailed(format!(
210 "form.ask: duplicate question id `{id}`"
211 )));
212 }
213 let named = ToolArgs {
214 positional: Vec::new(),
215 named: fields.clone(),
216 };
217 let kind = parse_form_kind(&named)?;
218 questions.push(FormQuestion { id, kind });
219 }
220 let first = questions[0].kind.clone();
221 Ok((CompositeForm { questions }, first, true))
222 }
223 (Some(value), _) => Err(RuntimeError::TypeMismatch {
224 expected: "list<struct>".into(),
225 actual: value.kind_name().into(),
226 }),
227 (None, _) => {
228 let kind = parse_form_kind(args)?;
229 Ok((
230 CompositeForm {
231 questions: vec![FormQuestion {
232 id: "question".into(),
233 kind: kind.clone(),
234 }],
235 },
236 kind,
237 false,
238 ))
239 }
240 }
241}
242
243fn parse_form_kind(args: &ToolArgs) -> Result<FormKind, RuntimeError> {
244 let kind = named_str(args, "kind")?;
245 let prompt = named_str(args, "prompt")?;
246 match kind.as_str() {
247 "confirm" => Ok(FormKind::Confirm { prompt }),
248 "single_select" => {
249 let options = named_string_list(args, "options")?;
250 if options.is_empty() {
251 return Err(RuntimeError::ToolFailed(
252 "form.ask(single_select): options must be non-empty".into(),
253 ));
254 }
255 Ok(FormKind::SingleSelect { prompt, options })
256 }
257 "multi_select" => {
258 let options = named_string_list(args, "options")?;
259 if options.is_empty() {
260 return Err(RuntimeError::ToolFailed(
261 "form.ask(multi_select): options must be non-empty".into(),
262 ));
263 }
264 let min = named_usize(args, "min")?;
265 let max = named_usize(args, "max")?;
266 if let (Some(m), Some(mx)) = (min, max)
267 && mx < m
268 {
269 return Err(RuntimeError::ToolFailed(
270 "form.ask(multi_select): max must be >= min".into(),
271 ));
272 }
273 Ok(FormKind::MultiSelect {
274 prompt,
275 options,
276 min,
277 max,
278 })
279 }
280 "text" => {
281 let placeholder = named_opt_str(args, "placeholder")?;
282 let multiline = matches!(args.named("multiline"), Some(Value::Bool(true)));
283 Ok(FormKind::Text {
284 prompt,
285 placeholder,
286 multiline,
287 })
288 }
289 other => Err(RuntimeError::ToolFailed(format!(
290 "form.ask: unknown kind `{other}` (expected confirm | single_select | multi_select | text)"
291 ))),
292 }
293}
294
295fn named_str(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
296 match args.named(name) {
297 Some(Value::Str(s)) => Ok(s.clone()),
298 Some(v) => Err(RuntimeError::TypeMismatch {
299 expected: "string".into(),
300 actual: v.kind_name().into(),
301 }),
302 None => Err(RuntimeError::MissingArg(name.into())),
303 }
304}
305
306fn named_opt_str(args: &ToolArgs, name: &str) -> Result<Option<String>, RuntimeError> {
307 match args.named(name) {
308 Some(Value::Str(s)) => Ok(Some(s.clone())),
309 Some(Value::Unit) | None => Ok(None),
310 Some(v) => Err(RuntimeError::TypeMismatch {
311 expected: "string".into(),
312 actual: v.kind_name().into(),
313 }),
314 }
315}
316
317fn named_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
318 match args.named(name) {
319 Some(Value::List(items)) => items
320 .iter()
321 .map(|v| match v {
322 Value::Str(s) => Ok(s.clone()),
323 other => Err(RuntimeError::TypeMismatch {
324 expected: "string".into(),
325 actual: other.kind_name().into(),
326 }),
327 })
328 .collect(),
329 Some(v) => Err(RuntimeError::TypeMismatch {
330 expected: "list<string>".into(),
331 actual: v.kind_name().into(),
332 }),
333 None => Err(RuntimeError::MissingArg(name.into())),
334 }
335}
336
337fn named_usize(args: &ToolArgs, name: &str) -> Result<Option<usize>, RuntimeError> {
338 match args.named(name) {
339 Some(Value::Int(i)) if *i >= 0 => Ok(Some(*i as usize)),
340 Some(Value::Int(_)) => Err(RuntimeError::ToolFailed(format!(
341 "form.ask: `{name}` must be non-negative"
342 ))),
343 Some(Value::Unit) | None => Ok(None),
344 Some(v) => Err(RuntimeError::TypeMismatch {
345 expected: "int".into(),
346 actual: v.kind_name().into(),
347 }),
348 }
349}
350
351fn answer_to_value(answer: &FormAnswer) -> Value {
352 match answer {
353 FormAnswer::Confirmed { value } => Value::Struct(vec![
354 ("kind".into(), Value::Str("confirmed".into())),
355 ("value".into(), Value::Bool(*value)),
356 ]),
357 FormAnswer::Selected { index, label } => Value::Struct(vec![
358 ("kind".into(), Value::Str("selected".into())),
359 ("index".into(), Value::Int(*index as i64)),
360 ("label".into(), Value::Str(label.clone())),
361 ]),
362 FormAnswer::MultiSelected { indices, labels } => Value::Struct(vec![
363 ("kind".into(), Value::Str("multi_selected".into())),
364 (
365 "indices".into(),
366 Value::List(indices.iter().map(|i| Value::Int(*i as i64)).collect()),
367 ),
368 (
369 "labels".into(),
370 Value::List(labels.iter().map(|s| Value::Str(s.clone())).collect()),
371 ),
372 ]),
373 FormAnswer::TextEntered { text } => Value::Struct(vec![
374 ("kind".into(), Value::Str("text_entered".into())),
375 ("text".into(), Value::Str(text.clone())),
376 ]),
377 FormAnswer::Cancelled => {
378 Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
379 }
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use crate::form::FormKind;
387 use crate::tool::ToolArgs;
388
389 fn named(name: &str, v: Value) -> (String, Value) {
390 (name.into(), v)
391 }
392
393 #[tokio::test]
394 async fn local_form_timeout_keeps_pending_entry_answerable() {
395 let forms = crate::session::FormRegistry::new();
396 let _subscriber = forms.subscribe();
397 let form_id = "timed-out".to_string();
398 let form = crate::form::CompositeForm {
399 questions: vec![crate::form::FormQuestion {
400 id: "question".into(),
401 kind: FormKind::Confirm { prompt: "?".into() },
402 }],
403 };
404 let pending = PendingForm {
405 form_id: form_id.clone(),
406 run_id: crate::event::FlowRunId::now(),
407 tool_use_id: "tool".into(),
408 form,
409 kind: FormKind::Confirm { prompt: "?".into() },
410 emitted_at: chrono::Utc::now(),
411 };
412 let rx = forms.request(pending);
413 assert_eq!(
414 await_local_submission(&forms, form_id, rx, std::time::Duration::ZERO).await,
415 crate::form::FormSubmission::Rejected
416 );
417 assert_eq!(forms.list_pending().len(), 1);
418 assert!(forms.submit(
419 "timed-out",
420 crate::form::FormSubmission::Submitted {
421 answers: vec![FormAnswer::Confirmed { value: true }],
422 }
423 ));
424 assert!(forms.list_pending().is_empty());
425 }
426
427 #[test]
428 fn parse_composite_questions() {
429 let question = |id: &str, prompt: &str| {
430 Value::Struct(vec![
431 ("id".into(), Value::Str(id.into())),
432 ("kind".into(), Value::Str("text".into())),
433 ("prompt".into(), Value::Str(prompt.into())),
434 ])
435 };
436 let args = ToolArgs {
437 positional: vec![],
438 named: vec![(
439 "questions".into(),
440 Value::List(vec![question("name", "Name?"), question("team", "Team?")]),
441 )],
442 };
443 let (form, first, composite) = parse_form_request(&args).unwrap();
444 assert!(composite);
445 assert_eq!(form.questions.len(), 2);
446 assert_eq!(form.questions[0].id, "name");
447 assert_eq!(form.questions[1].id, "team");
448 assert!(matches!(first, FormKind::Text { .. }));
449 }
450
451 #[test]
452 fn parse_composite_questions_rejects_duplicate_ids() {
453 let question = |id: &str| {
454 Value::Struct(vec![
455 ("id".into(), Value::Str(id.into())),
456 ("kind".into(), Value::Str("confirm".into())),
457 ("prompt".into(), Value::Str("Continue?".into())),
458 ])
459 };
460 let args = ToolArgs {
461 positional: vec![],
462 named: vec![(
463 "questions".into(),
464 Value::List(vec![question("same"), question("same")]),
465 )],
466 };
467 let error = parse_form_request(&args).unwrap_err();
468 assert!(error.to_string().contains("duplicate question id"));
469 }
470
471 #[test]
472 fn parse_confirm_kind() {
473 let args = ToolArgs {
474 positional: vec![],
475 named: vec![
476 named("kind", Value::Str("confirm".into())),
477 named("prompt", Value::Str("sure?".into())),
478 ],
479 };
480 assert!(matches!(
481 parse_form_kind(&args).unwrap(),
482 FormKind::Confirm { .. }
483 ));
484 }
485
486 #[test]
487 fn parse_single_select_rejects_empty_options() {
488 let args = ToolArgs {
489 positional: vec![],
490 named: vec![
491 named("kind", Value::Str("single_select".into())),
492 named("prompt", Value::Str("pick".into())),
493 named("options", Value::List(vec![])),
494 ],
495 };
496 let err = parse_form_kind(&args).unwrap_err();
497 assert!(err.to_string().contains("non-empty"));
498 }
499
500 #[test]
501 fn parse_multi_select_validates_bounds() {
502 let args = ToolArgs {
503 positional: vec![],
504 named: vec![
505 named("kind", Value::Str("multi_select".into())),
506 named("prompt", Value::Str("tags".into())),
507 named(
508 "options",
509 Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
510 ),
511 named("min", Value::Int(3)),
512 named("max", Value::Int(1)),
513 ],
514 };
515 let err = parse_form_kind(&args).unwrap_err();
516 assert!(err.to_string().contains("max must be >= min"));
517 }
518
519 #[test]
520 fn parse_text_defaults_multiline_to_false() {
521 let args = ToolArgs {
522 positional: vec![],
523 named: vec![
524 named("kind", Value::Str("text".into())),
525 named("prompt", Value::Str("name?".into())),
526 ],
527 };
528 match parse_form_kind(&args).unwrap() {
529 FormKind::Text { multiline, .. } => assert!(!multiline),
530 other => panic!("expected text, got {other:?}"),
531 }
532 }
533
534 #[test]
535 fn parse_unknown_kind_errors_with_hint() {
536 let args = ToolArgs {
537 positional: vec![],
538 named: vec![
539 named("kind", Value::Str("weird".into())),
540 named("prompt", Value::Str("?".into())),
541 ],
542 };
543 let err = parse_form_kind(&args).unwrap_err();
544 assert!(err.to_string().contains("weird"));
545 assert!(err.to_string().contains("confirm"));
546 }
547
548 #[test]
549 fn answer_confirmed_becomes_struct() {
550 let v = answer_to_value(&FormAnswer::Confirmed { value: true });
551 assert_eq!(v.field("kind").unwrap().kind_name(), "string");
552 assert!(matches!(v.field("value"), Some(Value::Bool(true))));
553 }
554
555 #[test]
556 fn answer_multi_selected_carries_indices_and_labels() {
557 let v = answer_to_value(&FormAnswer::MultiSelected {
558 indices: vec![0, 2],
559 labels: vec!["a".into(), "c".into()],
560 });
561 let indices = match v.field("indices").unwrap() {
562 Value::List(l) => l,
563 _ => panic!("expected list"),
564 };
565 assert_eq!(indices.len(), 2);
566 }
567
568 #[test]
569 fn answer_cancelled_is_kind_only_struct() {
570 let v = answer_to_value(&FormAnswer::Cancelled);
571 assert!(matches!(v.field("kind"), Some(Value::Str(s)) if s == "cancelled"));
572 assert!(v.field("value").is_none());
573 }
574}