Skip to main content

atman_runtime/tools/
form.rs

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            // Daemon clients drive the modal over RPC via the prompt
82            // resolver; the in-process TUI subscribes to FormRegistry.
83            // Pick whichever the runtime host wired up, prefer the
84            // resolver so daemon overrides an accidental fallback.
85            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}