heartbit_core/tool/builtins/
question.rs1use std::future::Future;
2use std::pin::Pin;
3use std::sync::Arc;
4
5use serde::{Deserialize, Serialize};
6use serde_json::json;
7
8use crate::error::Error;
9use crate::llm::types::ToolDefinition;
10use crate::tool::{Tool, ToolOutput};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct QuestionRequest {
17 pub questions: Vec<Question>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Question {
24 pub question: String,
26 pub header: String,
28 pub options: Vec<QuestionOption>,
30 pub multiple: bool,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct QuestionOption {
37 pub label: String,
39 pub description: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct QuestionResponse {
46 pub answers: Vec<Vec<String>>,
48}
49
50pub type OnQuestion = dyn Fn(QuestionRequest) -> Pin<Box<dyn Future<Output = Result<QuestionResponse, Error>> + Send>>
52 + Send
53 + Sync;
54
55pub struct QuestionTool {
66 on_question: Arc<OnQuestion>,
67}
68
69impl QuestionTool {
70 pub fn new(on_question: Arc<OnQuestion>) -> Self {
72 Self { on_question }
73 }
74}
75
76impl Tool for QuestionTool {
77 fn definition(&self) -> ToolDefinition {
78 ToolDefinition {
79 name: "question".into(),
80 description: "Ask the user structured questions with predefined options. \
81 Use this when you need clarification or a decision from the user."
82 .into(),
83 input_schema: json!({
84 "type": "object",
85 "properties": {
86 "questions": {
87 "type": "array",
88 "items": {
89 "type": "object",
90 "properties": {
91 "question": {
92 "type": "string",
93 "description": "The question to ask"
94 },
95 "header": {
96 "type": "string",
97 "description": "Short label (max 12 chars)"
98 },
99 "options": {
100 "type": "array",
101 "minItems": 2,
102 "items": {
103 "type": "object",
104 "properties": {
105 "label": {"type": "string"},
106 "description": {"type": "string"}
107 },
108 "required": ["label", "description"]
109 }
110 },
111 "multiple": {
112 "type": "boolean",
113 "description": "Allow multiple selections"
114 }
115 },
116 "required": ["question", "header", "options", "multiple"]
117 }
118 }
119 },
120 "required": ["questions"]
121 }),
122 }
123 }
124
125 fn execute(
126 &self,
127 _ctx: &crate::ExecutionContext,
128 input: serde_json::Value,
129 ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>> {
130 Box::pin(async move {
131 let questions_value = input
132 .get("questions")
133 .ok_or_else(|| Error::Agent("questions is required".into()))?;
134
135 let questions: Vec<Question> = serde_json::from_value(questions_value.clone())
136 .map_err(|e| Error::Agent(format!("Invalid questions format: {e}")))?;
137
138 if questions.is_empty() {
139 return Ok(ToolOutput::error("At least one question is required."));
140 }
141 for q in &questions {
142 if q.options.len() < 2 {
143 return Ok(ToolOutput::error(format!(
144 "Question '{}' must have at least 2 options.",
145 q.header
146 )));
147 }
148 }
149
150 let request = QuestionRequest {
151 questions: questions.clone(),
152 };
153 let response = match (self.on_question)(request).await {
154 Ok(r) => r,
155 Err(e) => return Ok(ToolOutput::error(format!("Question failed: {e}"))),
156 };
157
158 if response.answers.len() != questions.len() {
159 return Ok(ToolOutput::error(format!(
160 "Expected {} answers but got {}",
161 questions.len(),
162 response.answers.len()
163 )));
164 }
165
166 let mut output = String::new();
168 for (i, q) in questions.iter().enumerate() {
169 let answers = &response.answers[i];
170 output.push_str(&format!("{}: {}\n", q.question, answers.join(", ")));
171 }
172
173 Ok(ToolOutput::success(output))
174 })
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn definition_has_correct_name() {
184 let callback: Arc<OnQuestion> = Arc::new(|_| {
185 Box::pin(async {
186 Ok(QuestionResponse {
187 answers: vec![vec!["A".into()]],
188 })
189 })
190 });
191 let tool = QuestionTool::new(callback);
192 assert_eq!(tool.definition().name, "question");
193 }
194
195 #[tokio::test]
196 async fn question_tool_asks_and_returns() {
197 let callback: Arc<OnQuestion> = Arc::new(|req| {
198 Box::pin(async move {
199 let mut answers = Vec::new();
200 for q in &req.questions {
201 answers.push(vec![q.options[0].label.clone()]);
202 }
203 Ok(QuestionResponse { answers })
204 })
205 });
206
207 let tool = QuestionTool::new(callback);
208 let result = tool
209 .execute(
210 &crate::ExecutionContext::default(),
211 json!({
212 "questions": [{
213 "question": "Which color?",
214 "header": "Color",
215 "options": [
216 {"label": "Red", "description": "A warm color"},
217 {"label": "Blue", "description": "A cool color"}
218 ],
219 "multiple": false
220 }]
221 }),
222 )
223 .await
224 .unwrap();
225 assert!(!result.is_error);
226 assert!(result.content.contains("Red"));
227 }
228
229 #[tokio::test]
230 async fn question_tool_empty_questions() {
231 let callback: Arc<OnQuestion> =
232 Arc::new(|_| Box::pin(async { Ok(QuestionResponse { answers: vec![] }) }));
233
234 let tool = QuestionTool::new(callback);
235 let result = tool
236 .execute(
237 &crate::ExecutionContext::default(),
238 json!({"questions": []}),
239 )
240 .await
241 .unwrap();
242 assert!(result.is_error);
243 assert!(result.content.contains("At least one question"));
244 }
245
246 #[tokio::test]
247 async fn question_with_too_few_options_rejected() {
248 let callback: Arc<OnQuestion> =
249 Arc::new(|_| Box::pin(async { Ok(QuestionResponse { answers: vec![] }) }));
250
251 let tool = QuestionTool::new(callback);
252
253 let result = tool
255 .execute(
256 &crate::ExecutionContext::default(),
257 json!({
258 "questions": [{
259 "question": "Pick one",
260 "header": "Choice",
261 "options": [],
262 "multiple": false
263 }]
264 }),
265 )
266 .await
267 .unwrap();
268 assert!(result.is_error);
269 assert!(result.content.contains("at least 2 options"));
270
271 let result = tool
273 .execute(
274 &crate::ExecutionContext::default(),
275 json!({
276 "questions": [{
277 "question": "Pick one",
278 "header": "Choice",
279 "options": [{"label": "Only", "description": "Single option"}],
280 "multiple": false
281 }]
282 }),
283 )
284 .await
285 .unwrap();
286 assert!(result.is_error);
287 assert!(result.content.contains("at least 2 options"));
288 }
289
290 #[tokio::test]
291 async fn question_tool_rejects_mismatched_answer_count() {
292 let callback: Arc<OnQuestion> = Arc::new(|_| {
294 Box::pin(async {
295 Ok(QuestionResponse {
296 answers: vec![vec!["A".into()], vec!["B".into()]],
297 })
298 })
299 });
300
301 let tool = QuestionTool::new(callback);
302 let result = tool
303 .execute(
304 &crate::ExecutionContext::default(),
305 json!({
306 "questions": [{
307 "question": "Pick one",
308 "header": "Choice",
309 "options": [
310 {"label": "A", "description": "Option A"},
311 {"label": "B", "description": "Option B"}
312 ],
313 "multiple": false
314 }]
315 }),
316 )
317 .await
318 .unwrap();
319 assert!(result.is_error);
320 assert!(
321 result.content.contains("Expected 1 answers but got 2"),
322 "got: {}",
323 result.content
324 );
325 }
326
327 #[tokio::test]
328 async fn question_tool_callback_error_returns_tool_error() {
329 let callback: Arc<OnQuestion> =
330 Arc::new(|_| Box::pin(async { Err(Error::Agent("User cancelled".into())) }));
331
332 let tool = QuestionTool::new(callback);
333 let result = tool
334 .execute(
335 &crate::ExecutionContext::default(),
336 json!({
337 "questions": [{
338 "question": "Pick one",
339 "header": "Choice",
340 "options": [
341 {"label": "A", "description": "Option A"},
342 {"label": "B", "description": "Option B"}
343 ],
344 "multiple": false
345 }]
346 }),
347 )
348 .await
349 .unwrap(); assert!(result.is_error);
351 assert!(result.content.contains("User cancelled"));
352 }
353}