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