telos_agent/tools/builtin/
ask_user_question.rs1use async_trait::async_trait;
2use serde_json::{Value, json};
3
4use crate::error::AgentError;
5use crate::tools::api::{InterruptBehavior, Tool, ToolContext, ToolDefinition, ToolOutput};
6
7pub struct AskUserQuestionTool;
13
14fn make_schema() -> Value {
15 serde_json::from_str(
16 r#"{
17 "type": "object",
18 "properties": {
19 "questions": {
20 "type": "array",
21 "items": {
22 "type": "object",
23 "properties": {
24 "question": {
25 "type": "string",
26 "description": "The question text to display"
27 },
28 "header": {
29 "type": "string",
30 "description": "Section header for the question"
31 },
32 "options": {
33 "type": "array",
34 "items": {
35 "type": "object",
36 "properties": {
37 "label": {
38 "type": "string",
39 "description": "Short label for the option"
40 },
41 "description": {
42 "type": "string",
43 "description": "Detailed description of the option"
44 }
45 },
46 "required": ["label", "description"]
47 },
48 "description": "Available answer choices"
49 },
50 "multiSelect": {
51 "type": "boolean",
52 "default": false,
53 "description": "Allow selecting multiple options"
54 }
55 },
56 "required": ["question", "header", "options"]
57 }
58 }
59 },
60 "required": ["questions"]
61 }"#,
62 )
63 .expect("static schema is valid JSON")
64}
65
66#[async_trait]
67impl Tool for AskUserQuestionTool {
68 fn definition(&self) -> ToolDefinition {
69 ToolDefinition {
70 name: "AskUserQuestion".into(),
71 description: "Ask the user one or more questions with optional selections. Returns structured questions for the host to present.".into(),
72 input_schema: make_schema(),
73 }
74 }
75
76 fn prompt_text(&self) -> Option<&'static str> {
77 Some(
78 "Use AskUserQuestion to collect user preferences or disambiguate requirements when multiple valid choices exist. \
79Provide 2-4 concrete options with concise descriptions. Do not ask questions you can infer from context or project conventions.",
80 )
81 }
82
83 fn is_concurrency_safe(&self, _: &Value) -> bool {
84 true
85 }
86
87 fn interrupt_behavior(&self) -> InterruptBehavior {
88 InterruptBehavior::Block
89 }
90
91 async fn invoke(&self, args: Value, _: ToolContext) -> Result<ToolOutput, AgentError> {
92 let questions = args
93 .get("questions")
94 .and_then(|v| v.as_array())
95 .ok_or_else(|| AgentError::Validation("missing questions array".into()))?;
96
97 if questions.is_empty() {
98 return Err(AgentError::Validation("questions array is empty".into()));
99 }
100
101 for (i, q) in questions.iter().enumerate() {
103 if q.get("question").and_then(|v| v.as_str()).is_none_or(|s| s.is_empty()) {
104 return Err(AgentError::Validation(format!(
105 "question at index {i} is missing a non-empty `question` field"
106 )));
107 }
108 if q.get("header").and_then(|v| v.as_str()).is_none_or(|s| s.is_empty()) {
109 return Err(AgentError::Validation(format!(
110 "question at index {i} is missing a non-empty `header` field"
111 )));
112 }
113 let options = q.get("options").and_then(|v| v.as_array());
114 match options {
115 Some(opts) if opts.is_empty() => {
116 return Err(AgentError::Validation(format!(
117 "question at index {i} has an empty `options` array"
118 )));
119 }
120 None => {
121 return Err(AgentError::Validation(format!(
122 "question at index {i} is missing `options` array"
123 )));
124 }
125 Some(_) => {}
126 }
127 }
128
129 Ok(ToolOutput::json(json!({
130 "status": "questions_ready",
131 "questions": questions,
132 "instruction": "Please answer these questions to continue."
133 })))
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use std::sync::Arc;
140
141 use serde_json::json;
142
143 use super::AskUserQuestionTool;
144 use crate::tools::api::{Tool, ToolContext};
145
146 fn test_context() -> ToolContext {
147 ToolContext {
148 session_id: "test".into(),
149 turn_id: 1,
150 tool_call_id: None,
151 cwd: std::env::current_dir().unwrap(),
152 env: Default::default(),
153 messages: Arc::new(vec![]),
154 progress: None,
155 read_file_state: Arc::new(tokio::sync::Mutex::new(Default::default())),
156 timeout: None,
157 max_file_read_bytes: 50 * 1024 * 1024,
158 }
159 }
160
161 #[tokio::test]
162 async fn preserves_windows_path_and_env_option_text() {
163 let output = AskUserQuestionTool
164 .invoke(
165 json!({
166 "questions": [{
167 "header": "Config",
168 "question": "Which location should be used?",
169 "options": [{
170 "label": "LOCALAPPDATA",
171 "description": r"Use %LOCALAPPDATA%\Telos\skills"
172 }, {
173 "label": "Project",
174 "description": r"Use C:\Users\alice\project\.telos"
175 }]
176 }]
177 }),
178 test_context(),
179 )
180 .await
181 .unwrap();
182
183 assert_eq!(output.content["status"], "questions_ready");
184 assert_eq!(
185 output.content["questions"][0]["options"][0]["description"],
186 r"Use %LOCALAPPDATA%\Telos\skills"
187 );
188 assert_eq!(
189 output.content["questions"][0]["options"][1]["description"],
190 r"Use C:\Users\alice\project\.telos"
191 );
192 }
193
194 #[tokio::test]
195 async fn rejects_empty_options_array() {
196 let err = AskUserQuestionTool
197 .invoke(
198 json!({
199 "questions": [{
200 "header": "Config",
201 "question": "Pick one",
202 "options": []
203 }]
204 }),
205 test_context(),
206 )
207 .await
208 .unwrap_err();
209
210 assert!(err.to_string().contains("empty `options` array"));
211 }
212}