Skip to main content

mecha_core/tool/
ask.rs

1//! Asking the user a question, as a tool.
2//!
3//! The model cannot otherwise stop and check: the loop runs until it stops
4//! calling tools, so an under-specified task is answered with a guess or with a
5//! whole turn budget spent hunting for something that does not exist. That is
6//! not hypothetical — it is what the `ambiguity` tag in the eval rig measures,
7//! and it is the weakest tag in the set.
8//!
9//! Making it a *tool* rather than a prompting convention buys two things. The
10//! model can block on a human mid-run, which is the mechanism it lacked. And
11//! asking becomes a **trace** assertion rather than a rubric a judge grades:
12//! `expect.tools: ["ask_user"]` is deterministic and free, where "did it ask
13//! instead of guessing?" is a second model's opinion that changes between runs.
14//!
15//! Only registered where a human is actually present. A batch worker or an eval
16//! case has nobody to answer, and a tool that blocks forever is worse than one
17//! that does not exist.
18
19use super::{Capabilities, Tool, ToolCtx, ToolOutput};
20use anyhow::Result;
21use async_trait::async_trait;
22use serde_json::{json, Value};
23use std::sync::Arc;
24
25/// Something that can put a question to a person and wait for the answer.
26///
27/// Implemented by the front-end, for the same reason [`super::Approver`] is: it
28/// is the interface that owns stdin, and core must not assume there is a
29/// terminal at all.
30#[async_trait]
31pub trait Asker: Send + Sync {
32    /// `None` when the user declined to answer — closing the modal, or a
33    /// front-end shutting down. Never blocks forever by contract.
34    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    /// Read-only, which is also what makes it available while planning — the
85    /// phase where asking matters most.
86    fn read_only(&self) -> bool {
87        true
88    }
89
90    /// Nothing. The user is the principal, not a third party: marking their own
91    /// answer as untrusted would arm the trifecta interlock every time the
92    /// model asked a question, which would make the tool unusable next to any
93    /// private data.
94    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            // An error result rather than an `Err`: the model should be able to
125            // carry on with its best guess and say that it did, not have the
126            // run die because someone pressed escape.
127            // Measured, and the first wording was actively harmful: telling the
128            // model to "proceed with your best interpretation" made it invent a
129            // contractor name and rate — precisely the failure the case that
130            // caught it exists to detect. A decline must not read as
131            // permission to guess.
132            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        // An error *result*, not an `Err`: pressing escape should not end the
198        // run, it should hand the model something it can act on.
199        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        // An empty row in a picker is a row you can select and nothing happens.
229        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        // The failure this guards: a model enumerates two options that are both
235        // wrong, and the harness quietly coerces the reply to the nearest one.
236        // A forced choice between wrong answers is worse than no question — the
237        // `false-premise` eval case exists because "your question is wrong" is
238        // sometimes the correct answer.
239        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        // Marking it untrusted would arm the trifecta interlock every time the
266        // model asked a question, which would make the tool unusable beside any
267        // private data — exactly the situation where you most want to ask.
268        let (tool, _) = tool(None);
269        assert_eq!(tool.capabilities(), Capabilities::default());
270        // Read-only, which is also what keeps it available while planning.
271        assert!(tool.read_only());
272    }
273}