use super::{Capabilities, Tool, ToolCtx, ToolOutput};
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::Arc;
#[async_trait]
pub trait Asker: Send + Sync {
async fn ask(&self, question: &str, options: &[String]) -> Option<String>;
async fn ask_in(&self, ctx: &ToolCtx, question: &str, options: &[String]) -> Option<String> {
let _ = ctx;
self.ask(question, options).await
}
}
pub struct AskUserTool {
asker: Arc<dyn Asker>,
}
impl AskUserTool {
pub fn new(asker: Arc<dyn Asker>) -> Self {
AskUserTool { asker }
}
}
#[async_trait]
impl Tool for AskUserTool {
fn name(&self) -> &str {
"ask_user"
}
fn description(&self) -> &str {
"Ask the user a question and wait for their answer. Use this when the task is \
ambiguous and guessing would waste the work — an unknown name, two readings of \
the request, a missing value. Prefer asking early over discovering halfway \
through that you assumed wrong.\n\
\n\
Offer 2-4 concrete `options` only when you are confident the answer is one of \
them. Leave them out when the space is not really enumerable — an open question \
invites the answer you did not think of. The user can always reply with \
something outside your list, including that the question itself is wrong, so do \
not add a catch-all option and do not treat a list as exhaustive."
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question, in one sentence."
},
"options": {
"type": "array",
"items": {"type": "string"},
"description": "Concrete choices, if the answer is a selection."
}
},
"required": ["question"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
Capabilities::default()
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let question = input
.get("question")
.and_then(Value::as_str)
.unwrap_or("")
.trim();
if question.is_empty() {
return Ok(ToolOutput::err(
"ask_user needs a `question`. Say what you need to know in one sentence.",
));
}
let options: Vec<String> = input
.get("options")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
})
.unwrap_or_default();
match self.asker.ask_in(ctx, question, &options).await {
Some(answer) => Ok(ToolOutput::ok(answer)),
None => Ok(ToolOutput::err(
"The user did not answer. Do not invent the missing information. If the \
task can be done without it, do it and state plainly what you assumed; \
otherwise say what you still need and stop.",
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
struct Canned {
answer: Option<String>,
seen: Mutex<Vec<(String, Vec<String>)>>,
}
#[async_trait]
impl Asker for Canned {
async fn ask(&self, question: &str, options: &[String]) -> Option<String> {
self.seen
.lock()
.unwrap()
.push((question.to_string(), options.to_vec()));
self.answer.clone()
}
}
fn tool(answer: Option<&str>) -> (AskUserTool, Arc<Canned>) {
let canned = Arc::new(Canned {
answer: answer.map(str::to_string),
seen: Mutex::new(Vec::new()),
});
(AskUserTool::new(canned.clone()), canned)
}
#[tokio::test]
async fn the_answer_comes_back_as_the_tool_result() {
let (tool, canned) = tool(Some("the second one"));
let out = tool
.call(
json!({"question": "which invoice?", "options": ["March", "April"]}),
&ToolCtx::default(),
)
.await
.unwrap();
assert!(!out.is_error);
assert_eq!(out.content, "the second one");
let seen = canned.seen.lock().unwrap();
assert_eq!(seen[0].0, "which invoice?");
assert_eq!(seen[0].1, vec!["March", "April"]);
}
#[tokio::test]
async fn a_declined_question_tells_the_model_to_carry_on_rather_than_killing_the_run() {
let (tool, _) = tool(None);
let out = tool
.call(json!({"question": "which?"}), &ToolCtx::default())
.await
.unwrap();
assert!(out.is_error);
assert!(out.content.contains("Do not invent"), "{}", out.content);
}
#[tokio::test]
async fn an_empty_question_is_refused_before_anyone_is_interrupted() {
let (tool, canned) = tool(Some("x"));
let out = tool
.call(json!({"question": " "}), &ToolCtx::default())
.await
.unwrap();
assert!(out.is_error);
assert!(
canned.seen.lock().unwrap().is_empty(),
"the user was interrupted for nothing"
);
}
#[tokio::test]
async fn blank_and_non_string_options_are_dropped_rather_than_rendered() {
let (tool, canned) = tool(Some("a"));
tool.call(
json!({"question": "which?", "options": [" A ", "", 7, "B"]}),
&ToolCtx::default(),
)
.await
.unwrap();
assert_eq!(canned.seen.lock().unwrap()[0].1, vec!["A", "B"]);
}
#[tokio::test]
async fn an_answer_outside_the_offered_list_comes_back_untouched() {
let (tool, _) = tool(Some("neither — you are in the wrong repository"));
let out = tool
.call(
json!({"question": "which file?", "options": ["a.md", "b.md"]}),
&ToolCtx::default(),
)
.await
.unwrap();
assert!(!out.is_error);
assert_eq!(out.content, "neither — you are in the wrong repository");
}
#[test]
fn the_description_does_not_teach_the_model_to_force_a_choice() {
let (tool, _) = tool(None);
let d = tool.description();
assert!(d.contains("not really enumerable") || d.contains("not add a catch-all"));
assert!(
d.contains("outside your list"),
"the model is never told the list is not binding"
);
}
#[test]
fn the_users_own_answer_is_not_third_party_content() {
let (tool, _) = tool(None);
assert_eq!(tool.capabilities(), Capabilities::default());
assert!(tool.read_only());
}
}