car_server_core/assistant/
net_tools.rs1use std::time::Duration;
13
14use async_trait::async_trait;
15use car_engine::ToolExecutor;
16use serde_json::{json, Value};
17
18const MAX_BODY_BYTES: usize = 64 * 1024;
20const DEFAULT_TIMEOUT_SECS: u64 = 30;
22const MAX_TIMEOUT_SECS: u64 = 120;
23
24fn head(s: &str, cap: usize) -> String {
27 if s.len() <= cap {
28 return s.to_string();
29 }
30 let mut end = cap;
31 while !s.is_char_boundary(end) {
32 end -= 1;
33 }
34 format!("{}…[truncated]…", &s[..end])
35}
36
37pub fn net_tool_defs() -> Vec<Value> {
40 vec![
41 json!({
42 "name": "http_request",
43 "description": "Fetch a URL or call an HTTP API. Defaults to GET. \
44 Returns the requested URL, final URL after redirects, response status, \
45 HTML title when present, and a size-capped body. \
46 Runs from the host, so it works even when the \
47 filesystem/shell are sandboxed offline.",
48 "parameters": {
49 "type": "object",
50 "properties": {
51 "url": { "type": "string", "description": "Absolute http(s) URL." },
52 "method": { "type": "string", "description": "HTTP method (default GET)." },
53 "headers": { "type": "object", "description": "Optional request headers." },
54 "body": { "type": "string", "description": "Optional request body (for POST/PUT/…)." },
55 "timeout_secs": { "type": "integer", "description": "Wall-clock limit (default 30, max 120)." }
56 },
57 "required": ["url"]
58 },
59 "mutating": true,
60 "tier": "full_access"
61 }),
62 json!({
63 "name": "web_search",
64 "description": "Search the web for current facts and return a short list \
65 of results (title, url, snippet). Use when you need \
66 information you don't already have.",
67 "parameters": {
68 "type": "object",
69 "properties": {
70 "query": { "type": "string", "description": "What to search for." },
71 "max_results": { "type": "integer", "description": "How many results to return (default 5)." }
72 },
73 "required": ["query"]
74 },
75 "tier": "full_access"
76 }),
77 ]
78}
79
80pub struct NetTools {
82 client: reqwest::Client,
83}
84
85impl Default for NetTools {
86 fn default() -> Self {
87 Self::new()
88 }
89}
90
91impl NetTools {
92 pub fn new() -> Self {
93 let client = reqwest::Client::builder()
94 .user_agent("car-assistant/1.0")
95 .build()
96 .unwrap_or_default();
97 Self { client }
98 }
99
100 async fn http_request(&self, params: &Value) -> Result<Value, String> {
101 let url = params
102 .get("url")
103 .and_then(Value::as_str)
104 .ok_or("http_request requires a 'url' string")?;
105 if !(url.starts_with("http://") || url.starts_with("https://")) {
106 return Err("url must be an absolute http(s) URL".into());
107 }
108 let method = params
109 .get("method")
110 .and_then(Value::as_str)
111 .unwrap_or("GET")
112 .to_uppercase();
113 let m = reqwest::Method::from_bytes(method.as_bytes())
114 .map_err(|_| format!("invalid HTTP method '{method}'"))?;
115 let secs = params
116 .get("timeout_secs")
117 .and_then(Value::as_u64)
118 .unwrap_or(DEFAULT_TIMEOUT_SECS)
119 .clamp(1, MAX_TIMEOUT_SECS);
120
121 let mut req = self
122 .client
123 .request(m, url)
124 .timeout(Duration::from_secs(secs));
125 if let Some(headers) = params.get("headers").and_then(Value::as_object) {
126 for (k, v) in headers {
127 if let Some(vs) = v.as_str() {
128 req = req.header(k, vs);
129 }
130 }
131 }
132 if let Some(body) = params.get("body").and_then(Value::as_str) {
133 req = req.body(body.to_string());
134 }
135
136 let resp = req
137 .send()
138 .await
139 .map_err(|e| format!("request failed: {e}"))?;
140 let status = resp.status().as_u16();
141 let final_url = resp.url().to_string();
142 let text = resp
143 .text()
144 .await
145 .map_err(|e| format!("failed to read response body: {e}"))?;
146 Ok(json!({
147 "requested_url": url,
148 "final_url": final_url,
149 "status": status,
150 "title": html_title(&text),
151 "body": head(&text, MAX_BODY_BYTES),
152 }))
153 }
154
155 async fn web_search(&self, params: &Value) -> Result<Value, String> {
156 let query = params
157 .get("query")
158 .and_then(Value::as_str)
159 .ok_or("web_search requires a 'query' string")?;
160 let max = params
161 .get("max_results")
162 .and_then(Value::as_u64)
163 .unwrap_or(5)
164 .clamp(1, 15) as usize;
165
166 if let Ok(html) = self.fetch_ddg_html(query).await {
170 let results = parse_ddg_html(&html, max);
171 if !results.is_empty() {
172 return Ok(json!({ "query": query, "results": results }));
173 }
174 }
175
176 let url = format!(
179 "https://api.duckduckgo.com/?q={}&format=json&no_html=1&no_redirect=1&t=car-assistant",
180 urlencode(query)
181 );
182 let v: Value = self
183 .client
184 .get(&url)
185 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
186 .send()
187 .await
188 .map_err(|e| format!("search failed: {e}"))?
189 .json()
190 .await
191 .map_err(|e| format!("failed to parse search response: {e}"))?;
192
193 let mut results = Vec::new();
194 if let Some(topics) = v.get("RelatedTopics").and_then(Value::as_array) {
195 collect_topics(topics, &mut results, max);
196 }
197 let abstract_text = v
198 .get("AbstractText")
199 .and_then(Value::as_str)
200 .filter(|s| !s.is_empty())
201 .map(String::from);
202
203 Ok(json!({
204 "query": query,
205 "abstract": abstract_text,
206 "abstract_source": v.get("AbstractURL").and_then(Value::as_str),
207 "results": results,
208 "note": if results.is_empty() && abstract_text.is_none() {
209 "No results; consider a direct http_request to a source."
210 } else { "" },
211 }))
212 }
213
214 async fn fetch_ddg_html(&self, query: &str) -> Result<String, String> {
215 let url = format!("https://html.duckduckgo.com/html/?q={}", urlencode(query));
216 let resp = self
217 .client
218 .get(&url)
219 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
220 .send()
221 .await
222 .map_err(|e| format!("search failed: {e}"))?;
223 resp.text().await.map_err(|e| e.to_string())
224 }
225}
226
227fn html_title(body: &str) -> Option<String> {
234 let lower = body.to_ascii_lowercase();
235 let open = lower.find("<title")?;
236 let content_start = lower[open..].find('>')? + open + 1;
237 let content_end = lower[content_start..].find("</title>")? + content_start;
238 let title = unescape_entities(&body[content_start..content_end])
239 .split_whitespace()
240 .collect::<Vec<_>>()
241 .join(" ");
242 (!title.is_empty()).then_some(title)
243}
244
245fn parse_ddg_html(html: &str, max: usize) -> Vec<Value> {
246 let mut out = Vec::new();
247 for seg in html.split("class=\"result__a\"").skip(1) {
248 if out.len() >= max {
249 break;
250 }
251 let Some(href) = attr_after(seg, "href=\"") else {
252 continue;
253 };
254 let url = decode_uddg(&href);
255 if url.is_empty() {
256 continue;
257 }
258 let title = inner_text(seg);
259 let snippet = seg
260 .split_once("class=\"result__snippet\"")
261 .map(|(_, rest)| inner_text(rest))
262 .unwrap_or_default();
263 out.push(json!({ "title": title, "url": url, "snippet": snippet }));
264 }
265 out
266}
267
268fn attr_after(s: &str, marker: &str) -> Option<String> {
270 let start = s.find(marker)? + marker.len();
271 let end = s[start..].find('"')? + start;
272 Some(s[start..end].to_string())
273}
274
275fn inner_text(s: &str) -> String {
278 let after = s.find('>').map(|i| &s[i + 1..]).unwrap_or(s);
279 let raw = after.split('<').next().unwrap_or("");
280 unescape_entities(raw)
281 .split_whitespace()
282 .collect::<Vec<_>>()
283 .join(" ")
284}
285
286fn decode_uddg(href: &str) -> String {
289 let normalized = href.replace("&", "&");
290 if let Some(idx) = normalized.find("uddg=") {
291 let rest = &normalized[idx + 5..];
292 let enc = rest.split('&').next().unwrap_or("");
293 return percent_decode(enc);
294 }
295 if let Some(stripped) = normalized.strip_prefix("//") {
296 return format!("https://{stripped}");
297 }
298 normalized
299}
300
301fn percent_decode(s: &str) -> String {
303 let bytes = s.as_bytes();
304 let mut out = Vec::with_capacity(bytes.len());
305 let mut i = 0;
306 while i < bytes.len() {
307 match bytes[i] {
308 b'%' if i + 2 < bytes.len() => {
309 let hi = (bytes[i + 1] as char).to_digit(16);
310 let lo = (bytes[i + 2] as char).to_digit(16);
311 if let (Some(hi), Some(lo)) = (hi, lo) {
312 out.push((hi * 16 + lo) as u8);
313 i += 3;
314 continue;
315 }
316 out.push(bytes[i]);
317 i += 1;
318 }
319 b'+' => {
320 out.push(b' ');
321 i += 1;
322 }
323 b => {
324 out.push(b);
325 i += 1;
326 }
327 }
328 }
329 String::from_utf8_lossy(&out).into_owned()
330}
331
332fn unescape_entities(s: &str) -> String {
334 s.replace("&", "&")
335 .replace("<", "<")
336 .replace(">", ">")
337 .replace(""", "\"")
338 .replace("'", "'")
339 .replace("'", "'")
340}
341
342fn collect_topics(topics: &[Value], out: &mut Vec<Value>, max: usize) {
345 for t in topics {
346 if out.len() >= max {
347 return;
348 }
349 if let Some(sub) = t.get("Topics").and_then(Value::as_array) {
350 collect_topics(sub, out, max);
351 continue;
352 }
353 let text = t.get("Text").and_then(Value::as_str).unwrap_or("");
354 let url = t.get("FirstURL").and_then(Value::as_str).unwrap_or("");
355 if text.is_empty() && url.is_empty() {
356 continue;
357 }
358 let title = text.split(" - ").next().unwrap_or(text);
359 out.push(json!({ "title": title, "url": url, "snippet": text }));
360 }
361}
362
363fn urlencode(s: &str) -> String {
366 let mut out = String::with_capacity(s.len());
367 for b in s.bytes() {
368 match b {
369 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
370 out.push(b as char)
371 }
372 _ => out.push_str(&format!("%{b:02X}")),
373 }
374 }
375 out
376}
377
378#[async_trait]
379impl ToolExecutor for NetTools {
380 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
381 match tool {
382 "http_request" => self.http_request(params).await,
383 "web_search" => self.web_search(params).await,
384 other => Err(format!("unknown tool: '{other}'")),
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn head_truncates_on_char_boundary() {
397 let s = "ééé"; let t = head(s, 3);
399 assert!(t.starts_with('é') && t.ends_with("…[truncated]…"));
400 }
401
402 #[test]
403 fn urlencode_escapes_spaces_and_specials() {
404 assert_eq!(urlencode("a b&c"), "a%20b%26c");
405 assert_eq!(urlencode("plain-text_1.0~"), "plain-text_1.0~");
406 }
407
408 #[test]
409 fn net_tool_defs_advertises_both() {
410 let defs = net_tool_defs();
411 let http = defs
412 .iter()
413 .find(|d| d["name"] == "http_request")
414 .expect("http_request def");
415 assert_eq!(http["mutating"], true);
416 assert_eq!(http["tier"], "full_access");
417 let search = defs
418 .iter()
419 .find(|d| d["name"] == "web_search")
420 .expect("web_search def");
421 assert_eq!(search["tier"], "full_access");
422 assert!(search.get("mutating").is_none());
423 }
424
425 #[tokio::test]
426 async fn http_request_reports_the_requested_and_redirect_destination_urls() {
427 use tokio::io::{AsyncReadExt, AsyncWriteExt};
428
429 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
430 let address = listener.local_addr().unwrap();
431 let server = tokio::spawn(async move {
432 for redirected in [false, true] {
433 let (mut socket, _) = listener.accept().await.unwrap();
434 let mut request = [0u8; 2048];
435 let _ = socket.read(&mut request).await.unwrap();
436 let response = if redirected {
437 "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 49\r\nConnection: close\r\n\r\n<html><title> Final page </title><p>ok</p></html>"
438 } else {
439 "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
440 };
441 socket.write_all(response.as_bytes()).await.unwrap();
442 }
443 });
444
445 let requested = format!("http://{address}/start");
446 let result = NetTools::new()
447 .execute("http_request", &json!({"url": requested}))
448 .await
449 .unwrap();
450 server.await.unwrap();
451 assert_eq!(result["requested_url"], requested);
452 assert_eq!(result["final_url"], format!("http://{address}/final"));
453 assert_eq!(result["status"], 200);
454 assert_eq!(result["title"], "Final page");
455 }
456
457 #[tokio::test]
458 async fn http_request_rejects_non_http_url() {
459 let nt = NetTools::new();
460 let err = nt
461 .execute("http_request", &json!({ "url": "file:///etc/passwd" }))
462 .await
463 .unwrap_err();
464 assert!(err.contains("absolute http(s)"), "{err}");
465 }
466
467 #[tokio::test]
468 async fn unknown_tool_falls_through() {
469 let nt = NetTools::new();
470 let err = nt.execute("teleport", &json!({})).await.unwrap_err();
471 assert!(err.starts_with("unknown tool"), "{err}");
472 }
473
474 #[test]
475 fn percent_decode_handles_encoded_urls() {
476 assert_eq!(
477 percent_decode("https%3A%2F%2Fexample.com%2Fa%20b"),
478 "https://example.com/a b"
479 );
480 }
481
482 #[test]
483 fn decode_uddg_extracts_destination() {
484 let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org%2F&rut=abc";
485 assert_eq!(decode_uddg(href), "https://rust-lang.org/");
486 }
487
488 #[test]
489 fn parse_ddg_html_extracts_results() {
490 let html = r##"
492 <div class="result">
493 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fdoc.rust-lang.org%2Fstd%2F&rut=x">The Rust Standard Library</a>
494 <a class="result__snippet" href="#">Documentation for the Rust standard library.</a>
495 </div>
496 <div class="result">
497 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F">crates.io</a>
498 <a class="result__snippet" href="#">The Rust package registry.</a>
499 </div>
500 "##;
501 let results = parse_ddg_html(html, 5);
502 assert_eq!(results.len(), 2);
503 assert_eq!(results[0]["url"], "https://doc.rust-lang.org/std/");
504 assert_eq!(results[0]["title"], "The Rust Standard Library");
505 assert!(results[0]["snippet"]
506 .as_str()
507 .unwrap()
508 .contains("standard library"));
509 assert_eq!(results[1]["url"], "https://crates.io/");
510 assert_eq!(parse_ddg_html(html, 1).len(), 1);
512 }
513}