1use std::sync::Arc;
2
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use reqwest::Client;
6use serde_json::{Value, json};
7
8use openhelm_sdk::{Skill, Tool, ToolDefinition, ToolOutput};
9
10const DEFAULT_MAX_BODY_BYTES: usize = 15 * 1024 * 1024; struct HttpClient {
13 client: Client,
14 max_body_bytes: usize,
15}
16
17impl HttpClient {
18 fn new(max_body_bytes: usize) -> Self {
19 let client = Client::builder()
20 .user_agent("openhelm-http/0.1.0")
21 .build()
22 .expect("Failed to build HTTP client");
23 Self {
24 client,
25 max_body_bytes,
26 }
27 }
28}
29
30fn url_arg(args: &Value) -> Result<&str> {
31 args["url"]
32 .as_str()
33 .context("Missing required 'url' argument")
34}
35
36fn apply_headers(mut builder: reqwest::RequestBuilder, args: &Value) -> reqwest::RequestBuilder {
37 if let Some(headers) = args.get("headers").and_then(|header| header.as_object()) {
38 for (key, value) in headers {
39 if let Some(val) = value.as_str() {
40 builder = builder.header(key.as_str(), val);
41 }
42 }
43 }
44 builder
45}
46
47fn format_headers(headers: &reqwest::header::HeaderMap) -> String {
48 headers
49 .iter()
50 .filter_map(|(name, value)| {
51 value
52 .to_str()
53 .ok()
54 .map(|val| format!(" {}: {}\n", name, val))
55 })
56 .collect()
57}
58
59fn format_response(
60 status: reqwest::StatusCode,
61 headers: &reqwest::header::HeaderMap,
62 body: Option<&str>,
63 max_body_bytes: usize,
64) -> String {
65 let mut out = format!("Status: {}\nHeaders:\n{}", status, format_headers(headers));
66 if let Some(body) = body {
67 out.push('\n');
68 if body.len() > max_body_bytes {
69 out.push_str(&body[..max_body_bytes]);
70 out.push_str(&format!(
71 "\n\n--- truncated ({} bytes total, showing first {}) ---",
72 body.len(),
73 max_body_bytes
74 ));
75 } else {
76 out.push_str(body);
77 }
78 }
79 out
80}
81
82struct HttpGetTool(Arc<HttpClient>);
83
84#[async_trait]
85impl Tool for HttpGetTool {
86 fn name(&self) -> &'static str {
87 "http_get"
88 }
89
90 fn definition(&self) -> ToolDefinition {
91 ToolDefinition::function(
92 self.name(),
93 "Perform an HTTP GET request and return the response",
94 json!({
95 "type": "object",
96 "properties": {
97 "url": { "type": "string", "description": "The URL to request" },
98 "headers": {
99 "type": "object",
100 "description": "Optional HTTP headers as key-value pairs",
101 "additionalProperties": { "type": "string" }
102 }
103 },
104 "required": ["url"]
105 }),
106 )
107 }
108
109 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
110 let url = url_arg(args)?;
111 let builder = apply_headers(self.0.client.get(url), args);
112 let response = builder.send().await.context("HTTP GET request failed")?;
113 let status = response.status();
114 let headers = response.headers().clone();
115 let body = response
116 .text()
117 .await
118 .context("Failed to read response body")?;
119
120 Ok(ToolOutput {
121 success: status.is_success(),
122 output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
123 })
124 }
125}
126
127struct HttpPostTool(Arc<HttpClient>);
128
129#[async_trait]
130impl Tool for HttpPostTool {
131 fn name(&self) -> &'static str {
132 "http_post"
133 }
134
135 fn definition(&self) -> ToolDefinition {
136 ToolDefinition::function(
137 self.name(),
138 "Perform an HTTP POST request with an optional JSON body and return the response",
139 json!({
140 "type": "object",
141 "properties": {
142 "url": { "type": "string", "description": "The URL to request" },
143 "headers": {
144 "type": "object",
145 "description": "Optional HTTP headers as key-value pairs",
146 "additionalProperties": { "type": "string" }
147 },
148 "body": {
149 "type": "object",
150 "description": "Optional JSON body to send with the request"
151 }
152 },
153 "required": ["url"]
154 }),
155 )
156 }
157
158 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
159 let url = url_arg(args)?;
160 let mut builder = apply_headers(self.0.client.post(url), args);
161 if let Some(body) = args.get("body") {
162 builder = builder.json(body);
163 }
164 let response = builder.send().await.context("HTTP POST request failed")?;
165 let status = response.status();
166 let headers = response.headers().clone();
167 let body = response
168 .text()
169 .await
170 .context("Failed to read response body")?;
171
172 Ok(ToolOutput {
173 success: status.is_success(),
174 output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
175 })
176 }
177}
178
179struct HttpPutTool(Arc<HttpClient>);
180
181#[async_trait]
182impl Tool for HttpPutTool {
183 fn name(&self) -> &'static str {
184 "http_put"
185 }
186
187 fn definition(&self) -> ToolDefinition {
188 ToolDefinition::function(
189 self.name(),
190 "Perform an HTTP PUT request with an optional JSON body and return the response",
191 json!({
192 "type": "object",
193 "properties": {
194 "url": { "type": "string", "description": "The URL to request" },
195 "headers": {
196 "type": "object",
197 "description": "Optional HTTP headers as key-value pairs",
198 "additionalProperties": { "type": "string" }
199 },
200 "body": {
201 "type": "object",
202 "description": "Optional JSON body to send with the request"
203 }
204 },
205 "required": ["url"]
206 }),
207 )
208 }
209
210 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
211 let url = url_arg(args)?;
212 let mut builder = apply_headers(self.0.client.put(url), args);
213 if let Some(body) = args.get("body") {
214 builder = builder.json(body);
215 }
216 let response = builder.send().await.context("HTTP PUT request failed")?;
217 let status = response.status();
218 let headers = response.headers().clone();
219 let body = response
220 .text()
221 .await
222 .context("Failed to read response body")?;
223
224 Ok(ToolOutput {
225 success: status.is_success(),
226 output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
227 })
228 }
229}
230
231struct HttpPatchTool(Arc<HttpClient>);
232
233#[async_trait]
234impl Tool for HttpPatchTool {
235 fn name(&self) -> &'static str {
236 "http_patch"
237 }
238
239 fn definition(&self) -> ToolDefinition {
240 ToolDefinition::function(
241 self.name(),
242 "Perform an HTTP PATCH request with an optional JSON body and return the response",
243 json!({
244 "type": "object",
245 "properties": {
246 "url": { "type": "string", "description": "The URL to request" },
247 "headers": {
248 "type": "object",
249 "description": "Optional HTTP headers as key-value pairs",
250 "additionalProperties": { "type": "string" }
251 },
252 "body": {
253 "type": "object",
254 "description": "Optional JSON body to send with the request"
255 }
256 },
257 "required": ["url"]
258 }),
259 )
260 }
261
262 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
263 let url = url_arg(args)?;
264 let mut builder = apply_headers(self.0.client.patch(url), args);
265 if let Some(body) = args.get("body") {
266 builder = builder.json(body);
267 }
268 let response = builder.send().await.context("HTTP PATCH request failed")?;
269 let status = response.status();
270 let headers = response.headers().clone();
271 let body = response
272 .text()
273 .await
274 .context("Failed to read response body")?;
275
276 Ok(ToolOutput {
277 success: status.is_success(),
278 output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
279 })
280 }
281}
282
283struct HttpDeleteTool(Arc<HttpClient>);
284
285#[async_trait]
286impl Tool for HttpDeleteTool {
287 fn name(&self) -> &'static str {
288 "http_delete"
289 }
290
291 fn definition(&self) -> ToolDefinition {
292 ToolDefinition::function(
293 self.name(),
294 "Perform an HTTP DELETE request and return the response",
295 json!({
296 "type": "object",
297 "properties": {
298 "url": { "type": "string", "description": "The URL to request" },
299 "headers": {
300 "type": "object",
301 "description": "Optional HTTP headers as key-value pairs",
302 "additionalProperties": { "type": "string" }
303 }
304 },
305 "required": ["url"]
306 }),
307 )
308 }
309
310 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
311 let url = url_arg(args)?;
312 let builder = apply_headers(self.0.client.delete(url), args);
313 let response = builder.send().await.context("HTTP DELETE request failed")?;
314 let status = response.status();
315 let headers = response.headers().clone();
316 let body = response
317 .text()
318 .await
319 .context("Failed to read response body")?;
320
321 Ok(ToolOutput {
322 success: status.is_success(),
323 output: format_response(status, &headers, Some(&body), self.0.max_body_bytes),
324 })
325 }
326}
327
328struct HttpHeadTool(Arc<HttpClient>);
329
330#[async_trait]
331impl Tool for HttpHeadTool {
332 fn name(&self) -> &'static str {
333 "http_head"
334 }
335
336 fn definition(&self) -> ToolDefinition {
337 ToolDefinition::function(
338 self.name(),
339 "Perform an HTTP HEAD request and return status and headers (no body)",
340 json!({
341 "type": "object",
342 "properties": {
343 "url": { "type": "string", "description": "The URL to request" },
344 "headers": {
345 "type": "object",
346 "description": "Optional HTTP headers as key-value pairs",
347 "additionalProperties": { "type": "string" }
348 }
349 },
350 "required": ["url"]
351 }),
352 )
353 }
354
355 async fn execute(&self, args: &Value) -> Result<ToolOutput> {
356 let url = url_arg(args)?;
357 let builder = apply_headers(self.0.client.head(url), args);
358 let response = builder.send().await.context("HTTP HEAD request failed")?;
359 let status = response.status();
360 let headers = response.headers().clone();
361
362 Ok(ToolOutput {
363 success: status.is_success(),
364 output: format_response(status, &headers, None, 0),
365 })
366 }
367}
368
369pub struct HttpSkill;
370
371#[async_trait]
372impl Skill for HttpSkill {
373 fn name(&self) -> &'static str {
374 "http"
375 }
376
377 async fn build_tools(&self, config: Option<&toml::Value>) -> Result<Vec<Box<dyn Tool>>> {
378 let max_body_bytes = config
379 .and_then(|cfg| cfg.get("max_body_bytes"))
380 .and_then(|val| val.as_integer())
381 .map(|bytes| bytes as usize)
382 .unwrap_or(DEFAULT_MAX_BODY_BYTES);
383
384 let client = Arc::new(HttpClient::new(max_body_bytes));
385
386 Ok(vec![
387 Box::new(HttpGetTool(client.clone())),
388 Box::new(HttpPostTool(client.clone())),
389 Box::new(HttpPutTool(client.clone())),
390 Box::new(HttpPatchTool(client.clone())),
391 Box::new(HttpDeleteTool(client.clone())),
392 Box::new(HttpHeadTool(client)),
393 ])
394 }
395}