Skip to main content

zeroclaw_runtime/tools/
skill_http.rs

1//! HTTP-based tool derived from a skill's `[[tools]]` section.
2//!
3//! Each `SkillTool` with `kind = "http"` is converted into a `SkillHttpTool`
4//! that implements the `Tool` trait. The command field is used as the URL
5//! template and args are substituted as query parameters or path segments.
6
7use async_trait::async_trait;
8use std::collections::HashMap;
9use std::time::Duration;
10use zeroclaw_api::tool::{Tool, ToolResult};
11
12/// Maximum response body size (1 MB).
13const MAX_RESPONSE_BYTES: usize = 1_048_576;
14/// HTTP request timeout (seconds).
15const HTTP_TIMEOUT_SECS: u64 = 30;
16
17/// A tool derived from a skill's `[[tools]]` section that makes HTTP requests.
18pub struct SkillHttpTool {
19    tool_name: String,
20    tool_description: String,
21    url_template: String,
22    args: HashMap<String, String>,
23}
24
25impl SkillHttpTool {
26    /// Create a new skill HTTP tool.
27    ///
28    /// The tool name is prefixed with the skill name (`skill_name__tool_name`)
29    /// to prevent collisions with built-in tools.
30    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    /// Substitute `{{arg_name}}` placeholders in the URL template with
62    /// the provided argument values.
63    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        // Validate URL scheme
94        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}