zeroclaw_runtime/tools/
skill_http.rs1use async_trait::async_trait;
8use std::collections::HashMap;
9use std::time::Duration;
10use zeroclaw_api::tool::{Tool, ToolResult};
11
12const MAX_RESPONSE_BYTES: usize = 1_048_576;
14const HTTP_TIMEOUT_SECS: u64 = 30;
16
17pub struct SkillHttpTool {
19 tool_name: String,
20 tool_description: String,
21 url_template: String,
22 args: HashMap<String, String>,
23}
24
25impl SkillHttpTool {
26 pub fn new(skill_name: &str, tool: &crate::skills::SkillTool) -> Self {
31 Self {
32 tool_name: format!("{}__{}", skill_name, tool.name),
33 tool_description: tool.description.clone(),
34 url_template: tool.command.clone(),
35 args: tool.args.clone(),
36 }
37 }
38
39 fn build_parameters_schema(&self) -> serde_json::Value {
40 let mut properties = serde_json::Map::new();
41 let mut required = Vec::new();
42
43 for (name, description) in &self.args {
44 properties.insert(
45 name.clone(),
46 serde_json::json!({
47 "type": "string",
48 "description": description
49 }),
50 );
51 required.push(serde_json::Value::String(name.clone()));
52 }
53
54 serde_json::json!({
55 "type": "object",
56 "properties": properties,
57 "required": required
58 })
59 }
60
61 fn substitute_args(&self, args: &serde_json::Value) -> String {
64 let mut url = self.url_template.clone();
65 if let Some(obj) = args.as_object() {
66 for (key, value) in obj {
67 let placeholder = format!("{{{{{}}}}}", key);
68 let replacement = value.as_str().unwrap_or_default();
69 url = url.replace(&placeholder, replacement);
70 }
71 }
72 url
73 }
74}
75
76#[async_trait]
77impl Tool for SkillHttpTool {
78 fn name(&self) -> &str {
79 &self.tool_name
80 }
81
82 fn description(&self) -> &str {
83 &self.tool_description
84 }
85
86 fn parameters_schema(&self) -> serde_json::Value {
87 self.build_parameters_schema()
88 }
89
90 async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
91 let url = self.substitute_args(&args);
92
93 if !url.starts_with("http://") && !url.starts_with("https://") {
95 return Ok(ToolResult {
96 success: false,
97 output: String::new(),
98 error: Some(format!(
99 "Only http:// and https:// URLs are allowed, got: {url}"
100 )),
101 });
102 }
103
104 let client = reqwest::Client::builder()
105 .timeout(Duration::from_secs(HTTP_TIMEOUT_SECS))
106 .build()
107 .map_err(|e| {
108 ::zeroclaw_log::record!(
109 ERROR,
110 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
111 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
112 .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
113 "skill_http tool: reqwest client build failed"
114 );
115 anyhow::Error::msg(format!("Failed to build HTTP client: {e}"))
116 })?;
117
118 let response = match client.get(&url).send().await {
119 Ok(resp) => resp,
120 Err(e) => {
121 return Ok(ToolResult {
122 success: false,
123 output: String::new(),
124 error: Some(format!("HTTP request failed: {e}")),
125 });
126 }
127 };
128
129 let status = response.status();
130 let body = match response.bytes().await {
131 Ok(bytes) => {
132 let mut text = String::from_utf8_lossy(&bytes).to_string();
133 if text.len() > MAX_RESPONSE_BYTES {
134 let mut b = MAX_RESPONSE_BYTES.min(text.len());
135 while b > 0 && !text.is_char_boundary(b) {
136 b -= 1;
137 }
138 text.truncate(b);
139 text.push_str("\n... [response truncated at 1MB]");
140 }
141 text
142 }
143 Err(e) => {
144 return Ok(ToolResult {
145 success: false,
146 output: String::new(),
147 error: Some(format!("Failed to read response body: {e}")),
148 });
149 }
150 };
151
152 Ok(ToolResult {
153 success: status.is_success(),
154 output: body,
155 error: if status.is_success() {
156 None
157 } else {
158 Some(format!("HTTP {}", status))
159 },
160 })
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use crate::skills::SkillTool;
168
169 fn sample_http_tool() -> SkillTool {
170 let mut args = HashMap::new();
171 args.insert("city".to_string(), "City name to look up".to_string());
172
173 SkillTool {
174 name: "get_weather".to_string(),
175 description: "Fetch weather for a city".to_string(),
176 kind: "http".to_string(),
177 command: "https://api.example.com/weather?city={{city}}".to_string(),
178 args,
179 target: None,
180 locked_args: HashMap::new(),
181 }
182 }
183
184 #[test]
185 fn skill_http_tool_name_is_prefixed() {
186 let tool = SkillHttpTool::new("weather_skill", &sample_http_tool());
187 assert_eq!(tool.name(), "weather_skill__get_weather");
188 }
189
190 #[test]
191 fn skill_http_tool_description() {
192 let tool = SkillHttpTool::new("weather_skill", &sample_http_tool());
193 assert_eq!(tool.description(), "Fetch weather for a city");
194 }
195
196 #[test]
197 fn skill_http_tool_parameters_schema() {
198 let tool = SkillHttpTool::new("weather_skill", &sample_http_tool());
199 let schema = tool.parameters_schema();
200
201 assert_eq!(schema["type"], "object");
202 assert!(schema["properties"]["city"].is_object());
203 assert_eq!(schema["properties"]["city"]["type"], "string");
204 }
205
206 #[test]
207 fn skill_http_tool_substitute_args() {
208 let tool = SkillHttpTool::new("weather_skill", &sample_http_tool());
209 let result = tool.substitute_args(&serde_json::json!({"city": "London"}));
210 assert_eq!(result, "https://api.example.com/weather?city=London");
211 }
212
213 #[test]
214 fn skill_http_tool_spec_roundtrip() {
215 let tool = SkillHttpTool::new("weather_skill", &sample_http_tool());
216 let spec = tool.spec();
217 assert_eq!(spec.name, "weather_skill__get_weather");
218 assert_eq!(spec.description, "Fetch weather for a city");
219 assert_eq!(spec.parameters["type"], "object");
220 }
221
222 #[test]
223 fn skill_http_tool_empty_args() {
224 let st = SkillTool {
225 name: "ping".to_string(),
226 description: "Ping endpoint".to_string(),
227 kind: "http".to_string(),
228 command: "https://api.example.com/ping".to_string(),
229 args: HashMap::new(),
230 target: None,
231 locked_args: HashMap::new(),
232 };
233 let tool = SkillHttpTool::new("s", &st);
234 let schema = tool.parameters_schema();
235 assert!(schema["properties"].as_object().unwrap().is_empty());
236 }
237}