microclaw_engine/tools/
clarify.rs1use 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
13const MAX_CHOICES: usize = 4;
16
17pub 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}