1use crate::core::{QuestionAnswers, QuestionPrompt};
2use crate::tool::{Tool, ToolResult, ToolSchema};
3use async_trait::async_trait;
4use serde::Deserialize;
5use serde_json::{Value, json};
6
7pub struct QuestionTool;
8
9#[derive(Debug, Deserialize)]
10pub struct QuestionArgs {
11 pub questions: Vec<QuestionPrompt>,
12}
13
14pub fn parse_question_args(args: Value) -> anyhow::Result<QuestionArgs> {
15 let parsed: QuestionArgs = serde_json::from_value(args)?;
16 if parsed.questions.is_empty() {
17 anyhow::bail!("questions must not be empty");
18 }
19
20 for (index, question) in parsed.questions.iter().enumerate() {
21 if question.question.trim().is_empty() {
22 anyhow::bail!("questions[{index}].question must not be empty");
23 }
24 if question.header.trim().is_empty() {
25 anyhow::bail!("questions[{index}].header must not be empty");
26 }
27 if question.options.is_empty() {
28 anyhow::bail!("questions[{index}].options must not be empty");
29 }
30 for (opt_index, option) in question.options.iter().enumerate() {
31 if option.label.trim().is_empty() {
32 anyhow::bail!("questions[{index}].options[{opt_index}].label must not be empty");
33 }
34 }
35 }
36
37 Ok(parsed)
38}
39
40pub fn question_result(questions: &[QuestionPrompt], answers: QuestionAnswers) -> ToolResult {
41 let formatted = questions
42 .iter()
43 .enumerate()
44 .map(|(idx, question)| {
45 let answer = answers
46 .get(idx)
47 .filter(|items| !items.is_empty())
48 .map(|items| items.join(", "))
49 .unwrap_or_else(|| "Unanswered".to_string());
50 format!("\"{}\"=\"{}\"", question.question, answer)
51 })
52 .collect::<Vec<_>>()
53 .join(", ");
54
55 ToolResult::ok_json_typed(
56 format!(
57 "Asked {} question{}",
58 questions.len(),
59 if questions.len() == 1 { "" } else { "s" }
60 ),
61 "application/vnd.hh.question+json",
62 json!({
63 "answers": answers,
64 "message": format!(
65 "User has answered your questions: {formatted}. You can now continue with the user's answers in mind."
66 ),
67 }),
68 )
69}
70
71#[async_trait]
72impl Tool for QuestionTool {
73 fn schema(&self) -> ToolSchema {
74 ToolSchema {
75 name: "question".to_string(),
76 description: "Ask the user questions during execution.".to_string(),
77 capability: Some("question".to_string()),
78 mutating: Some(false),
79 parameters: json!({
80 "type": "object",
81 "properties": {
82 "questions": {
83 "type": "array",
84 "description": "Questions to ask",
85 "items": {
86 "type": "object",
87 "properties": {
88 "question": {
89 "type": "string",
90 "description": "Complete question"
91 },
92 "header": {
93 "type": "string",
94 "description": "Very short label (max 30 chars)",
95 "maxLength": 30
96 },
97 "options": {
98 "type": "array",
99 "description": "Available choices",
100 "items": {
101 "type": "object",
102 "properties": {
103 "label": {
104 "type": "string",
105 "description": "Display text (1-5 words, concise)",
106 "maxLength": 30
107 },
108 "description": {
109 "type": "string",
110 "description": "Explanation of choice"
111 }
112 },
113 "required": ["label", "description"],
114 "additionalProperties": false
115 }
116 },
117 "multiple": {
118 "type": "boolean",
119 "description": "Allow selecting multiple choices"
120 },
121 "custom": {
122 "type": "boolean",
123 "description": "Allow typing a custom answer"
124 }
125 },
126 "required": ["question", "header", "options"],
127 "additionalProperties": false
128 }
129 }
130 },
131 "required": ["questions"],
132 "additionalProperties": false
133 }),
134 }
135 }
136
137 async fn execute(&self, _args: Value) -> ToolResult {
138 ToolResult::err_text(
139 "question_not_available",
140 "question tool can only be executed through the interactive agent loop",
141 )
142 }
143}