Skip to main content

heartbit_core/tool/builtins/
question.rs

1use 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// --- Types ---
13
14/// Request payload for the `question` builtin tool — one or more structured questions.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct QuestionRequest {
17    /// Questions to present to the user, in order.
18    pub questions: Vec<Question>,
19}
20
21/// A single structured question with predefined options.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct Question {
24    /// Prompt text shown to the user.
25    pub question: String,
26    /// Short heading rendered above the prompt.
27    pub header: String,
28    /// Selectable options.
29    pub options: Vec<QuestionOption>,
30    /// `true` allows multi-select; `false` requires a single choice.
31    pub multiple: bool,
32}
33
34/// One selectable option in a structured question.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct QuestionOption {
37    /// Option label (returned in the answer).
38    pub label: String,
39    /// Human-readable description shown alongside the label.
40    pub description: String,
41}
42
43/// Response payload — the user's selections, one entry per question.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct QuestionResponse {
46    /// Per-question list of selected labels.
47    pub answers: Vec<Vec<String>>,
48}
49
50/// Callback type for agent-to-user structured questions.
51pub type OnQuestion = dyn Fn(QuestionRequest) -> Pin<Box<dyn Future<Output = Result<QuestionResponse, Error>> + Send>>
52    + Send
53    + Sync;
54
55// --- Tool ---
56
57/// Builtin tool that pauses the agent to ask the user structured questions.
58///
59/// When the agent needs clarification before proceeding it can call this tool
60/// with a list of questions, each with a header and a set of labelled options.
61/// Execution is suspended until the registered `OnQuestion` callback returns
62/// the user's answers, enabling interactive human-in-the-loop flows within an
63/// otherwise autonomous run. Each question may allow single or multiple
64/// selections via the `multiple` flag.
65pub struct QuestionTool {
66    on_question: Arc<OnQuestion>,
67}
68
69impl QuestionTool {
70    /// Construct a `QuestionTool` that resolves answers via the supplied callback.
71    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            // Format answers
167            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        // Zero options
254        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        // One option (also rejected)
272        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        // Callback returns 2 answers but only 1 question asked
293        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(); // Should not propagate error
350        assert!(result.is_error);
351        assert!(result.content.contains("User cancelled"));
352    }
353}