Skip to main content

atman_runtime/tools/
form.rs

1use crate::error::RuntimeError;
2use crate::form::{FormAnswer, FormKind, 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             Returns a struct { kind, ... } where kind is one of \
32             confirmed | selected | multi_selected | text_entered | cancelled.",
33        )
34    }
35
36    fn input_schema(&self) -> serde_json::Value {
37        serde_json::json!({
38            "type": "object",
39            "properties": {
40                "kind": {"type": "string"},
41                "prompt": {"type": "string"},
42                "options": {"type": "array", "items": {"type": "string"}},
43                "min": {"type": "integer"},
44                "max": {"type": "integer"},
45                "placeholder": {"type": "string"},
46                "multiline": {"type": "boolean"}
47            },
48            "required": ["kind", "prompt"]
49        })
50    }
51
52    fn call<'a>(&'a self, args: ToolArgs, ctx: &'a ToolCtx) -> BoxFut<'a, ToolResult> {
53        Box::pin(async move {
54            let kind = parse_form_kind(&args)?;
55            // Daemon clients drive the modal over RPC via the prompt
56            // resolver; the in-process TUI subscribes to FormRegistry.
57            // Pick whichever the runtime host wired up, prefer the
58            // resolver so daemon overrides an accidental fallback.
59            if let Some(resolver) = ctx.prompt_resolver.clone() {
60                let id = crate::rendezvous::PromptId::now();
61                let payload = serde_json::to_value(&kind).unwrap_or(serde_json::Value::Null);
62                let timeout = std::time::Duration::from_secs(300);
63                let answer_json = crate::rendezvous::await_prompt_with_payload(
64                    &resolver, id, "form_ask", payload, timeout,
65                )
66                .await?;
67                let answer: FormAnswer =
68                    serde_json::from_value(answer_json.clone()).unwrap_or(FormAnswer::Cancelled);
69                return Ok(answer_to_value(&answer));
70            }
71            let forms = ctx.forms.as_ref().ok_or_else(|| {
72                RuntimeError::ToolFailed(
73                    "form.ask: no FormRegistry or PromptResolver attached".into(),
74                )
75            })?;
76            let run_id = ctx.flow_run_id.clone().ok_or_else(|| {
77                RuntimeError::ToolFailed("form.ask: no flow_run_id in ctx".into())
78            })?;
79            let form_id = uuid::Uuid::now_v7().to_string();
80            let pending = PendingForm {
81                form_id: form_id.clone(),
82                run_id,
83                tool_use_id: ctx.current_node_id.clone().unwrap_or_default(),
84                kind,
85                emitted_at: chrono::Utc::now(),
86            };
87            let rx = forms.request(pending);
88            let answer = rx.await.unwrap_or(FormAnswer::Cancelled);
89            Ok(answer_to_value(&answer))
90        })
91    }
92}
93
94fn parse_form_kind(args: &ToolArgs) -> Result<FormKind, RuntimeError> {
95    let kind = named_str(args, "kind")?;
96    let prompt = named_str(args, "prompt")?;
97    match kind.as_str() {
98        "confirm" => Ok(FormKind::Confirm { prompt }),
99        "single_select" => {
100            let options = named_string_list(args, "options")?;
101            if options.is_empty() {
102                return Err(RuntimeError::ToolFailed(
103                    "form.ask(single_select): options must be non-empty".into(),
104                ));
105            }
106            Ok(FormKind::SingleSelect { prompt, options })
107        }
108        "multi_select" => {
109            let options = named_string_list(args, "options")?;
110            if options.is_empty() {
111                return Err(RuntimeError::ToolFailed(
112                    "form.ask(multi_select): options must be non-empty".into(),
113                ));
114            }
115            let min = named_usize(args, "min")?;
116            let max = named_usize(args, "max")?;
117            if let (Some(m), Some(mx)) = (min, max)
118                && mx < m
119            {
120                return Err(RuntimeError::ToolFailed(
121                    "form.ask(multi_select): max must be >= min".into(),
122                ));
123            }
124            Ok(FormKind::MultiSelect {
125                prompt,
126                options,
127                min,
128                max,
129            })
130        }
131        "text" => {
132            let placeholder = named_opt_str(args, "placeholder")?;
133            let multiline = matches!(args.named("multiline"), Some(Value::Bool(true)));
134            Ok(FormKind::Text {
135                prompt,
136                placeholder,
137                multiline,
138            })
139        }
140        other => Err(RuntimeError::ToolFailed(format!(
141            "form.ask: unknown kind `{other}` (expected confirm | single_select | multi_select | text)"
142        ))),
143    }
144}
145
146fn named_str(args: &ToolArgs, name: &str) -> Result<String, RuntimeError> {
147    match args.named(name) {
148        Some(Value::Str(s)) => Ok(s.clone()),
149        Some(v) => Err(RuntimeError::TypeMismatch {
150            expected: "string".into(),
151            actual: v.kind_name().into(),
152        }),
153        None => Err(RuntimeError::MissingArg(name.into())),
154    }
155}
156
157fn named_opt_str(args: &ToolArgs, name: &str) -> Result<Option<String>, RuntimeError> {
158    match args.named(name) {
159        Some(Value::Str(s)) => Ok(Some(s.clone())),
160        Some(Value::Unit) | None => Ok(None),
161        Some(v) => Err(RuntimeError::TypeMismatch {
162            expected: "string".into(),
163            actual: v.kind_name().into(),
164        }),
165    }
166}
167
168fn named_string_list(args: &ToolArgs, name: &str) -> Result<Vec<String>, RuntimeError> {
169    match args.named(name) {
170        Some(Value::List(items)) => items
171            .iter()
172            .map(|v| match v {
173                Value::Str(s) => Ok(s.clone()),
174                other => Err(RuntimeError::TypeMismatch {
175                    expected: "string".into(),
176                    actual: other.kind_name().into(),
177                }),
178            })
179            .collect(),
180        Some(v) => Err(RuntimeError::TypeMismatch {
181            expected: "list<string>".into(),
182            actual: v.kind_name().into(),
183        }),
184        None => Err(RuntimeError::MissingArg(name.into())),
185    }
186}
187
188fn named_usize(args: &ToolArgs, name: &str) -> Result<Option<usize>, RuntimeError> {
189    match args.named(name) {
190        Some(Value::Int(i)) if *i >= 0 => Ok(Some(*i as usize)),
191        Some(Value::Int(_)) => Err(RuntimeError::ToolFailed(format!(
192            "form.ask: `{name}` must be non-negative"
193        ))),
194        Some(Value::Unit) | None => Ok(None),
195        Some(v) => Err(RuntimeError::TypeMismatch {
196            expected: "int".into(),
197            actual: v.kind_name().into(),
198        }),
199    }
200}
201
202fn answer_to_value(answer: &FormAnswer) -> Value {
203    match answer {
204        FormAnswer::Confirmed { value } => Value::Struct(vec![
205            ("kind".into(), Value::Str("confirmed".into())),
206            ("value".into(), Value::Bool(*value)),
207        ]),
208        FormAnswer::Selected { index, label } => Value::Struct(vec![
209            ("kind".into(), Value::Str("selected".into())),
210            ("index".into(), Value::Int(*index as i64)),
211            ("label".into(), Value::Str(label.clone())),
212        ]),
213        FormAnswer::MultiSelected { indices, labels } => Value::Struct(vec![
214            ("kind".into(), Value::Str("multi_selected".into())),
215            (
216                "indices".into(),
217                Value::List(indices.iter().map(|i| Value::Int(*i as i64)).collect()),
218            ),
219            (
220                "labels".into(),
221                Value::List(labels.iter().map(|s| Value::Str(s.clone())).collect()),
222            ),
223        ]),
224        FormAnswer::TextEntered { text } => Value::Struct(vec![
225            ("kind".into(), Value::Str("text_entered".into())),
226            ("text".into(), Value::Str(text.clone())),
227        ]),
228        FormAnswer::Cancelled => {
229            Value::Struct(vec![("kind".into(), Value::Str("cancelled".into()))])
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::form::FormKind;
238    use crate::tool::ToolArgs;
239
240    fn named(name: &str, v: Value) -> (String, Value) {
241        (name.into(), v)
242    }
243
244    #[test]
245    fn parse_confirm_kind() {
246        let args = ToolArgs {
247            positional: vec![],
248            named: vec![
249                named("kind", Value::Str("confirm".into())),
250                named("prompt", Value::Str("sure?".into())),
251            ],
252        };
253        assert!(matches!(
254            parse_form_kind(&args).unwrap(),
255            FormKind::Confirm { .. }
256        ));
257    }
258
259    #[test]
260    fn parse_single_select_rejects_empty_options() {
261        let args = ToolArgs {
262            positional: vec![],
263            named: vec![
264                named("kind", Value::Str("single_select".into())),
265                named("prompt", Value::Str("pick".into())),
266                named("options", Value::List(vec![])),
267            ],
268        };
269        let err = parse_form_kind(&args).unwrap_err();
270        assert!(err.to_string().contains("non-empty"));
271    }
272
273    #[test]
274    fn parse_multi_select_validates_bounds() {
275        let args = ToolArgs {
276            positional: vec![],
277            named: vec![
278                named("kind", Value::Str("multi_select".into())),
279                named("prompt", Value::Str("tags".into())),
280                named(
281                    "options",
282                    Value::List(vec![Value::Str("a".into()), Value::Str("b".into())]),
283                ),
284                named("min", Value::Int(3)),
285                named("max", Value::Int(1)),
286            ],
287        };
288        let err = parse_form_kind(&args).unwrap_err();
289        assert!(err.to_string().contains("max must be >= min"));
290    }
291
292    #[test]
293    fn parse_text_defaults_multiline_to_false() {
294        let args = ToolArgs {
295            positional: vec![],
296            named: vec![
297                named("kind", Value::Str("text".into())),
298                named("prompt", Value::Str("name?".into())),
299            ],
300        };
301        match parse_form_kind(&args).unwrap() {
302            FormKind::Text { multiline, .. } => assert!(!multiline),
303            other => panic!("expected text, got {other:?}"),
304        }
305    }
306
307    #[test]
308    fn parse_unknown_kind_errors_with_hint() {
309        let args = ToolArgs {
310            positional: vec![],
311            named: vec![
312                named("kind", Value::Str("weird".into())),
313                named("prompt", Value::Str("?".into())),
314            ],
315        };
316        let err = parse_form_kind(&args).unwrap_err();
317        assert!(err.to_string().contains("weird"));
318        assert!(err.to_string().contains("confirm"));
319    }
320
321    #[test]
322    fn answer_confirmed_becomes_struct() {
323        let v = answer_to_value(&FormAnswer::Confirmed { value: true });
324        assert_eq!(v.field("kind").unwrap().kind_name(), "string");
325        assert!(matches!(v.field("value"), Some(Value::Bool(true))));
326    }
327
328    #[test]
329    fn answer_multi_selected_carries_indices_and_labels() {
330        let v = answer_to_value(&FormAnswer::MultiSelected {
331            indices: vec![0, 2],
332            labels: vec!["a".into(), "c".into()],
333        });
334        let indices = match v.field("indices").unwrap() {
335            Value::List(l) => l,
336            _ => panic!("expected list"),
337        };
338        assert_eq!(indices.len(), 2);
339    }
340
341    #[test]
342    fn answer_cancelled_is_kind_only_struct() {
343        let v = answer_to_value(&FormAnswer::Cancelled);
344        assert!(matches!(v.field("kind"), Some(Value::Str(s)) if s == "cancelled"));
345        assert!(v.field("value").is_none());
346    }
347}