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    /// Like [`ask`], with the calling run's [`ToolCtx`] in hand.
37    ///
38    /// A front-end serving one conversation never needs it — the default
39    /// forwards to `ask` — but one agent serving many conversations must
40    /// route the question to the human who owns the run that asked, and the
41    /// context is the only thing that knows which run that is. The tool
42    /// calls this; the loop still learns nothing.
43    ///
44    /// [`ask`]: Asker::ask
45    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    /// Read-only, which is also what makes it available while planning — the
99    /// phase where asking matters most.
100    fn read_only(&self) -> bool {
101        true
102    }
103
104    /// Nothing. The user is the principal, not a third party: marking their own
105    /// answer as untrusted would arm the trifecta interlock every time the
106    /// model asked a question, which would make the tool unusable next to any
107    /// private data.
108    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            // An error result rather than an `Err`: the model should be able to
139            // carry on with its best guess and say that it did, not have the
140            // run die because someone pressed escape.
141            // Measured, and the first wording was actively harmful: telling the
142            // model to "proceed with your best interpretation" made it invent a
143            // contractor name and rate — precisely the failure the case that
144            // caught it exists to detect. A decline must not read as
145            // permission to guess.
146            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        // An error *result*, not an `Err`: pressing escape should not end the
212        // run, it should hand the model something it can act on.
213        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        // An empty row in a picker is a row you can select and nothing happens.
243        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        // The failure this guards: a model enumerates two options that are both
249        // wrong, and the harness quietly coerces the reply to the nearest one.
250        // A forced choice between wrong answers is worse than no question — the
251        // `false-premise` eval case exists because "your question is wrong" is
252        // sometimes the correct answer.
253        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        // Marking it untrusted would arm the trifecta interlock every time the
280        // model asked a question, which would make the tool unusable beside any
281        // private data — exactly the situation where you most want to ask.
282        let (tool, _) = tool(None);
283        assert_eq!(tool.capabilities(), Capabilities::default());
284        // Read-only, which is also what keeps it available while planning.
285        assert!(tool.read_only());
286    }
287}