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_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}