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