Skip to main content

telos_agent/tools/builtin/
ask_user_question.rs

1use async_trait::async_trait;
2use serde_json::{Value, json};
3
4use crate::error::AgentError;
5use crate::tools::api::{InterruptBehavior, Tool, ToolContext, ToolDefinition, ToolOutput};
6
7/// Tool that presents structured questions to the user.
8///
9/// This tool returns a `questions_ready` signal with the questions for the host
10/// application to present. The host is responsible for collecting answers and
11/// feeding them back in the next turn.
12pub 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        // Validate each question has required fields
102        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}