Skip to main content

hh_cli/tool/
question.rs

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}