Skip to main content

opendev_tools_impl/
ask_user.rs

1//! Ask user tool — pose structured questions to the user via a channel.
2
3use std::collections::HashMap;
4
5use opendev_runtime::{AskUserRequest, AskUserSender};
6use opendev_tools_core::{BaseTool, ToolContext, ToolResult};
7
8/// Tool for asking the user a question during agent execution.
9///
10/// When a `ask_tx` channel is set (TUI mode), the tool blocks until
11/// the user answers. When `None` (headless/pipe mode), the tool
12/// formats the question and returns immediately.
13#[derive(Debug)]
14pub struct AskUserTool {
15    /// Channel to send ask-user requests to the TUI.
16    ask_tx: Option<AskUserSender>,
17}
18
19impl AskUserTool {
20    /// Create an ask_user tool without a channel (headless mode).
21    pub fn new() -> Self {
22        Self { ask_tx: None }
23    }
24
25    /// Attach an ask-user channel for interactive (TUI) mode.
26    pub fn with_ask_tx(mut self, tx: AskUserSender) -> Self {
27        self.ask_tx = Some(tx);
28        self
29    }
30}
31
32impl Default for AskUserTool {
33    fn default() -> Self {
34        Self::new()
35    }
36}
37
38#[async_trait::async_trait]
39impl BaseTool for AskUserTool {
40    fn name(&self) -> &str {
41        "ask_user"
42    }
43
44    fn description(&self) -> &str {
45        "Ask the user a question and wait for their response. Use when clarification is needed."
46    }
47
48    fn parameter_schema(&self) -> serde_json::Value {
49        serde_json::json!({
50            "type": "object",
51            "properties": {
52                "question": {
53                    "type": "string",
54                    "description": "The question to ask the user"
55                },
56                "options": {
57                    "type": "array",
58                    "items": { "type": "string" },
59                    "description": "Optional list of choices for the user"
60                },
61                "default": {
62                    "type": "string",
63                    "description": "Default answer if user provides none"
64                }
65            },
66            "required": ["question"]
67        })
68    }
69
70    async fn execute(
71        &self,
72        args: HashMap<String, serde_json::Value>,
73        _ctx: &ToolContext,
74    ) -> ToolResult {
75        let question = match args.get("question").and_then(|v| v.as_str()) {
76            Some(q) => q,
77            None => return ToolResult::fail("question is required"),
78        };
79
80        let options: Vec<String> = args
81            .get("options")
82            .and_then(|v| v.as_array())
83            .map(|arr| {
84                arr.iter()
85                    .filter_map(|v| v.as_str().map(String::from))
86                    .collect()
87            })
88            .unwrap_or_default();
89
90        let default = args
91            .get("default")
92            .and_then(|v| v.as_str())
93            .map(String::from);
94
95        // --- Interactive mode: block until user answers ---
96        if let Some(ref tx) = self.ask_tx {
97            let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
98            if tx
99                .send(AskUserRequest {
100                    question: question.to_string(),
101                    options: options.clone(),
102                    default: default.clone(),
103                    response_tx: resp_tx,
104                })
105                .is_ok()
106            {
107                match resp_rx.await {
108                    Ok(answer) => {
109                        return ToolResult::ok(format!("User answered: {answer}"));
110                    }
111                    Err(_) => {
112                        // Channel dropped — fall through to headless
113                    }
114                }
115            }
116        }
117
118        // --- Headless mode: format question and return ---
119        let mut output = format!("Question: {question}");
120        if !options.is_empty() {
121            output.push_str("\nOptions:");
122            for (i, opt) in options.iter().enumerate() {
123                output.push_str(&format!("\n  {}. {opt}", i + 1));
124            }
125        }
126        if let Some(d) = &default {
127            output.push_str(&format!("\nDefault: {d}"));
128        }
129
130        let mut metadata = HashMap::new();
131        metadata.insert("requires_input".into(), serde_json::json!(true));
132        metadata.insert("question".into(), serde_json::json!(question));
133        if !options.is_empty() {
134            metadata.insert("options".into(), serde_json::json!(options));
135        }
136        if let Some(d) = &default {
137            metadata.insert("default".into(), serde_json::json!(d));
138        }
139
140        ToolResult::ok_with_metadata(output, metadata)
141    }
142}
143
144#[cfg(test)]
145#[path = "ask_user_tests.rs"]
146mod tests;