1use super::{Capabilities, Tool, ToolCtx, ToolOutput};
20use anyhow::Result;
21use async_trait::async_trait;
22use serde_json::{json, Value};
23use std::sync::Arc;
24
25#[async_trait]
31pub trait Asker: Send + Sync {
32 async fn ask(&self, question: &str, options: &[String]) -> Option<String>;
35}
36
37pub struct AskUserTool {
38 asker: Arc<dyn Asker>,
39}
40
41impl AskUserTool {
42 pub fn new(asker: Arc<dyn Asker>) -> Self {
43 AskUserTool { asker }
44 }
45}
46
47#[async_trait]
48impl Tool for AskUserTool {
49 fn name(&self) -> &str {
50 "ask_user"
51 }
52
53 fn description(&self) -> &str {
54 "Ask the user a question and wait for their answer. Use this when the task is \
55 ambiguous and guessing would waste the work — an unknown name, two readings of \
56 the request, a missing value. Prefer asking early over discovering halfway \
57 through that you assumed wrong.\n\
58 \n\
59 Offer 2-4 concrete `options` only when you are confident the answer is one of \
60 them. Leave them out when the space is not really enumerable — an open question \
61 invites the answer you did not think of. The user can always reply with \
62 something outside your list, including that the question itself is wrong, so do \
63 not add a catch-all option and do not treat a list as exhaustive."
64 }
65
66 fn input_schema(&self) -> Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "question": {
71 "type": "string",
72 "description": "The question, in one sentence."
73 },
74 "options": {
75 "type": "array",
76 "items": {"type": "string"},
77 "description": "Concrete choices, if the answer is a selection."
78 }
79 },
80 "required": ["question"]
81 })
82 }
83
84 fn read_only(&self) -> bool {
87 true
88 }
89
90 fn capabilities(&self) -> Capabilities {
95 Capabilities::default()
96 }
97
98 async fn call(&self, input: Value, _ctx: &ToolCtx) -> Result<ToolOutput> {
99 let question = input
100 .get("question")
101 .and_then(Value::as_str)
102 .unwrap_or("")
103 .trim();
104 if question.is_empty() {
105 return Ok(ToolOutput::err(
106 "ask_user needs a `question`. Say what you need to know in one sentence.",
107 ));
108 }
109
110 let options: Vec<String> = input
111 .get("options")
112 .and_then(Value::as_array)
113 .map(|a| {
114 a.iter()
115 .filter_map(|v| v.as_str())
116 .map(|s| s.trim().to_string())
117 .filter(|s| !s.is_empty())
118 .collect()
119 })
120 .unwrap_or_default();
121
122 match self.asker.ask(question, &options).await {
123 Some(answer) => Ok(ToolOutput::ok(answer)),
124 None => Ok(ToolOutput::err(
133 "The user did not answer. Do not invent the missing information. If the \
134 task can be done without it, do it and state plainly what you assumed; \
135 otherwise say what you still need and stop.",
136 )),
137 }
138 }
139}
140
141#[cfg(test)]
142mod tests {
143 use super::*;
144 use std::sync::Mutex;
145
146 struct Canned {
147 answer: Option<String>,
148 seen: Mutex<Vec<(String, Vec<String>)>>,
149 }
150
151 #[async_trait]
152 impl Asker for Canned {
153 async fn ask(&self, question: &str, options: &[String]) -> Option<String> {
154 self.seen
155 .lock()
156 .unwrap()
157 .push((question.to_string(), options.to_vec()));
158 self.answer.clone()
159 }
160 }
161
162 fn tool(answer: Option<&str>) -> (AskUserTool, Arc<Canned>) {
163 let canned = Arc::new(Canned {
164 answer: answer.map(str::to_string),
165 seen: Mutex::new(Vec::new()),
166 });
167 (AskUserTool::new(canned.clone()), canned)
168 }
169
170 #[tokio::test]
171 async fn the_answer_comes_back_as_the_tool_result() {
172 let (tool, canned) = tool(Some("the second one"));
173 let out = tool
174 .call(
175 json!({"question": "which invoice?", "options": ["March", "April"]}),
176 &ToolCtx::default(),
177 )
178 .await
179 .unwrap();
180
181 assert!(!out.is_error);
182 assert_eq!(out.content, "the second one");
183
184 let seen = canned.seen.lock().unwrap();
185 assert_eq!(seen[0].0, "which invoice?");
186 assert_eq!(seen[0].1, vec!["March", "April"]);
187 }
188
189 #[tokio::test]
190 async fn a_declined_question_tells_the_model_to_carry_on_rather_than_killing_the_run() {
191 let (tool, _) = tool(None);
192 let out = tool
193 .call(json!({"question": "which?"}), &ToolCtx::default())
194 .await
195 .unwrap();
196
197 assert!(out.is_error);
200 assert!(out.content.contains("Do not invent"), "{}", out.content);
201 }
202
203 #[tokio::test]
204 async fn an_empty_question_is_refused_before_anyone_is_interrupted() {
205 let (tool, canned) = tool(Some("x"));
206 let out = tool
207 .call(json!({"question": " "}), &ToolCtx::default())
208 .await
209 .unwrap();
210
211 assert!(out.is_error);
212 assert!(
213 canned.seen.lock().unwrap().is_empty(),
214 "the user was interrupted for nothing"
215 );
216 }
217
218 #[tokio::test]
219 async fn blank_and_non_string_options_are_dropped_rather_than_rendered() {
220 let (tool, canned) = tool(Some("a"));
221 tool.call(
222 json!({"question": "which?", "options": [" A ", "", 7, "B"]}),
223 &ToolCtx::default(),
224 )
225 .await
226 .unwrap();
227
228 assert_eq!(canned.seen.lock().unwrap()[0].1, vec!["A", "B"]);
230 }
231
232 #[tokio::test]
233 async fn an_answer_outside_the_offered_list_comes_back_untouched() {
234 let (tool, _) = tool(Some("neither — you are in the wrong repository"));
240 let out = tool
241 .call(
242 json!({"question": "which file?", "options": ["a.md", "b.md"]}),
243 &ToolCtx::default(),
244 )
245 .await
246 .unwrap();
247
248 assert!(!out.is_error);
249 assert_eq!(out.content, "neither — you are in the wrong repository");
250 }
251
252 #[test]
253 fn the_description_does_not_teach_the_model_to_force_a_choice() {
254 let (tool, _) = tool(None);
255 let d = tool.description();
256 assert!(d.contains("not really enumerable") || d.contains("not add a catch-all"));
257 assert!(
258 d.contains("outside your list"),
259 "the model is never told the list is not binding"
260 );
261 }
262
263 #[test]
264 fn the_users_own_answer_is_not_third_party_content() {
265 let (tool, _) = tool(None);
269 assert_eq!(tool.capabilities(), Capabilities::default());
270 assert!(tool.read_only());
272 }
273}