Skip to main content

microclaw_engine/tools/
clarify.rs

1use async_trait::async_trait;
2use serde_json::json;
3use std::sync::Arc;
4
5use crate::internal::channels::channel::deliver_and_store_bot_message;
6use crate::internal::channels::channel_adapter::ChannelRegistry;
7use crate::internal::storage::db::Database;
8use crate::internal::tool_runtime::runtime::auth_context_from_input;
9use microclaw_core::llm_types::ToolDefinition;
10
11use super::{schema_object, Tool, ToolResult};
12
13/// Maximum number of structured choices the agent can offer. Matches the
14/// cap enforced by hermes-agent's `clarify_tool.py`.
15const MAX_CHOICES: usize = 4;
16
17/// Clarify tool — ask the user a structured question and immediately return
18/// control so the next user turn supplies the answer.
19///
20/// Unlike hermes-agent's Python clarify tool (which blocks in CLI mode), the
21/// microclaw equivalent is send-and-release: the formatted question is
22/// delivered through the caller's channel via `deliver_and_store_bot_message`,
23/// and the tool result reminds the agent to stop and wait. The next user
24/// message continues the conversation naturally via microclaw's existing
25/// session-resume path.
26pub struct ClarifyTool {
27    channels: Arc<ChannelRegistry>,
28    db: Arc<Database>,
29    default_bot_username: String,
30    channel_bot_usernames: std::collections::HashMap<String, String>,
31}
32
33impl ClarifyTool {
34    pub fn new(
35        channels: Arc<ChannelRegistry>,
36        db: Arc<Database>,
37        default_bot_username: String,
38        channel_bot_usernames: std::collections::HashMap<String, String>,
39    ) -> Self {
40        Self {
41            channels,
42            db,
43            default_bot_username,
44            channel_bot_usernames,
45        }
46    }
47
48    fn bot_username_for_channel(&self, channel_name: &str) -> String {
49        self.channel_bot_usernames
50            .get(channel_name)
51            .cloned()
52            .unwrap_or_else(|| self.default_bot_username.clone())
53    }
54}
55
56fn render_message(question: &str, choices: &[String]) -> String {
57    if choices.is_empty() {
58        return question.to_string();
59    }
60    let mut lines = Vec::with_capacity(choices.len() + 2);
61    lines.push(question.to_string());
62    for (i, choice) in choices.iter().enumerate() {
63        lines.push(format!("{}. {choice}", i + 1));
64    }
65    lines.push(format!(
66        "{}. Other — reply with your own answer",
67        choices.len() + 1
68    ));
69    lines.join("\n")
70}
71
72#[async_trait]
73impl Tool for ClarifyTool {
74    fn name(&self) -> &str {
75        "clarify"
76    }
77
78    fn definition(&self) -> ToolDefinition {
79        ToolDefinition {
80            name: self.name().into(),
81            description: "Ask the user a clarifying question and pause for a \
82                reply. Use when the request is ambiguous, a decision has \
83                meaningful trade-offs, or you want post-task feedback. Supports \
84                up to 4 multiple-choice options (an 'Other' option is appended \
85                automatically) or pure open-ended mode. Do NOT use for yes/no \
86                confirmation of dangerous commands — the bash-tool approval \
87                path handles that."
88                .into(),
89            input_schema: schema_object(
90                json!({
91                    "question": {
92                        "type": "string",
93                        "description": "The question to present to the user."
94                    },
95                    "choices": {
96                        "type": "array",
97                        "items": {"type": "string"},
98                        "maxItems": MAX_CHOICES,
99                        "description": "Up to 4 predefined answer choices. Omit for free-form response."
100                    }
101                }),
102                &["question"],
103            ),
104        }
105    }
106
107    async fn execute(&self, input: serde_json::Value) -> ToolResult {
108        let Some(auth) = auth_context_from_input(&input) else {
109            return ToolResult::error(
110                "clarify requires an auth context (caller_chat_id) on the input".into(),
111            );
112        };
113        let question = match input.get("question").and_then(|v| v.as_str()) {
114            Some(q) if !q.trim().is_empty() => q.trim().to_string(),
115            _ => return ToolResult::error("Missing or empty required parameter: question".into()),
116        };
117        let choices: Vec<String> = input
118            .get("choices")
119            .and_then(|v| v.as_array())
120            .map(|arr| {
121                arr.iter()
122                    .filter_map(|v| v.as_str().map(|s| s.trim().to_string()))
123                    .filter(|s| !s.is_empty())
124                    .take(MAX_CHOICES)
125                    .collect()
126            })
127            .unwrap_or_default();
128
129        let rendered = render_message(&question, &choices);
130        let bot_username = self.bot_username_for_channel(auth.caller_channel.as_str());
131        if let Err(e) = deliver_and_store_bot_message(
132            &self.channels,
133            self.db.clone(),
134            &bot_username,
135            auth.caller_chat_id,
136            &rendered,
137        )
138        .await
139        {
140            return ToolResult::error(format!("failed to deliver clarify question: {e}"));
141        }
142
143        let mut summary = format!(
144            "clarify question sent on channel '{}' — stop this turn and wait for the \
145             user's next reply; session resume will pick up their answer.",
146            auth.caller_channel
147        );
148        if !choices.is_empty() {
149            summary.push_str(&format!(" Offered {} choice(s).", choices.len()));
150        }
151        ToolResult::success(summary)
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn renders_open_ended() {
161        assert_eq!(render_message("hi?", &[]), "hi?");
162    }
163
164    #[test]
165    fn renders_with_choices_and_other_option() {
166        let out = render_message("pick?", &["A".into(), "B".into()]);
167        assert!(out.contains("1. A"));
168        assert!(out.contains("2. B"));
169        assert!(out.contains("3. Other"));
170    }
171}