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 response status and a (size-capped) body. \
45 Runs from the host, so it works even when the \
46 filesystem/shell are sandboxed offline.",
47 "parameters": {
48 "type": "object",
49 "properties": {
50 "url": { "type": "string", "description": "Absolute http(s) URL." },
51 "method": { "type": "string", "description": "HTTP method (default GET)." },
52 "headers": { "type": "object", "description": "Optional request headers." },
53 "body": { "type": "string", "description": "Optional request body (for POST/PUT/…)." },
54 "timeout_secs": { "type": "integer", "description": "Wall-clock limit (default 30, max 120)." }
55 },
56 "required": ["url"]
57 },
58 "mutating": true,
59 "tier": "full_access"
60 }),
61 json!({
62 "name": "web_search",
63 "description": "Search the web for current facts and return a short list \
64 of results (title, url, snippet). Use when you need \
65 information you don't already have.",
66 "parameters": {
67 "type": "object",
68 "properties": {
69 "query": { "type": "string", "description": "What to search for." },
70 "max_results": { "type": "integer", "description": "How many results to return (default 5)." }
71 },
72 "required": ["query"]
73 },
74 "tier": "full_access"
75 }),
76 ]
77}
78
79pub struct NetTools {
81 client: reqwest::Client,
82}
83
84impl Default for NetTools {
85 fn default() -> Self {
86 Self::new()
87 }
88}
89
90impl NetTools {
91 pub fn new() -> Self {
92 let client = reqwest::Client::builder()
93 .user_agent("car-assistant/1.0")
94 .build()
95 .unwrap_or_default();
96 Self { client }
97 }
98
99 async fn http_request(&self, params: &Value) -> Result<Value, String> {
100 let url = params
101 .get("url")
102 .and_then(Value::as_str)
103 .ok_or("http_request requires a 'url' string")?;
104 if !(url.starts_with("http://") || url.starts_with("https://")) {
105 return Err("url must be an absolute http(s) URL".into());
106 }
107 let method = params
108 .get("method")
109 .and_then(Value::as_str)
110 .unwrap_or("GET")
111 .to_uppercase();
112 let m = reqwest::Method::from_bytes(method.as_bytes())
113 .map_err(|_| format!("invalid HTTP method '{method}'"))?;
114 let secs = params
115 .get("timeout_secs")
116 .and_then(Value::as_u64)
117 .unwrap_or(DEFAULT_TIMEOUT_SECS)
118 .clamp(1, MAX_TIMEOUT_SECS);
119
120 let mut req = self
121 .client
122 .request(m, url)
123 .timeout(Duration::from_secs(secs));
124 if let Some(headers) = params.get("headers").and_then(Value::as_object) {
125 for (k, v) in headers {
126 if let Some(vs) = v.as_str() {
127 req = req.header(k, vs);
128 }
129 }
130 }
131 if let Some(body) = params.get("body").and_then(Value::as_str) {
132 req = req.body(body.to_string());
133 }
134
135 let resp = req
136 .send()
137 .await
138 .map_err(|e| format!("request failed: {e}"))?;
139 let status = resp.status().as_u16();
140 let text = resp
141 .text()
142 .await
143 .map_err(|e| format!("failed to read response body: {e}"))?;
144 Ok(json!({
145 "status": status,
146 "body": head(&text, MAX_BODY_BYTES),
147 }))
148 }
149
150 async fn web_search(&self, params: &Value) -> Result<Value, String> {
151 let query = params
152 .get("query")
153 .and_then(Value::as_str)
154 .ok_or("web_search requires a 'query' string")?;
155 let max = params
156 .get("max_results")
157 .and_then(Value::as_u64)
158 .unwrap_or(5)
159 .clamp(1, 15) as usize;
160
161 if let Ok(html) = self.fetch_ddg_html(query).await {
165 let results = parse_ddg_html(&html, max);
166 if !results.is_empty() {
167 return Ok(json!({ "query": query, "results": results }));
168 }
169 }
170
171 let url = format!(
174 "https://api.duckduckgo.com/?q={}&format=json&no_html=1&no_redirect=1&t=car-assistant",
175 urlencode(query)
176 );
177 let v: Value = self
178 .client
179 .get(&url)
180 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
181 .send()
182 .await
183 .map_err(|e| format!("search failed: {e}"))?
184 .json()
185 .await
186 .map_err(|e| format!("failed to parse search response: {e}"))?;
187
188 let mut results = Vec::new();
189 if let Some(topics) = v.get("RelatedTopics").and_then(Value::as_array) {
190 collect_topics(topics, &mut results, max);
191 }
192 let abstract_text = v
193 .get("AbstractText")
194 .and_then(Value::as_str)
195 .filter(|s| !s.is_empty())
196 .map(String::from);
197
198 Ok(json!({
199 "query": query,
200 "abstract": abstract_text,
201 "abstract_source": v.get("AbstractURL").and_then(Value::as_str),
202 "results": results,
203 "note": if results.is_empty() && abstract_text.is_none() {
204 "No results; consider a direct http_request to a source."
205 } else { "" },
206 }))
207 }
208
209 async fn fetch_ddg_html(&self, query: &str) -> Result<String, String> {
210 let url = format!("https://html.duckduckgo.com/html/?q={}", urlencode(query));
211 let resp = self
212 .client
213 .get(&url)
214 .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
215 .send()
216 .await
217 .map_err(|e| format!("search failed: {e}"))?;
218 resp.text().await.map_err(|e| e.to_string())
219 }
220}
221
222fn parse_ddg_html(html: &str, max: usize) -> Vec<Value> {
229 let mut out = Vec::new();
230 for seg in html.split("class=\"result__a\"").skip(1) {
231 if out.len() >= max {
232 break;
233 }
234 let Some(href) = attr_after(seg, "href=\"") else {
235 continue;
236 };
237 let url = decode_uddg(&href);
238 if url.is_empty() {
239 continue;
240 }
241 let title = inner_text(seg);
242 let snippet = seg
243 .split_once("class=\"result__snippet\"")
244 .map(|(_, rest)| inner_text(rest))
245 .unwrap_or_default();
246 out.push(json!({ "title": title, "url": url, "snippet": snippet }));
247 }
248 out
249}
250
251fn attr_after(s: &str, marker: &str) -> Option<String> {
253 let start = s.find(marker)? + marker.len();
254 let end = s[start..].find('"')? + start;
255 Some(s[start..end].to_string())
256}
257
258fn inner_text(s: &str) -> String {
261 let after = s.find('>').map(|i| &s[i + 1..]).unwrap_or(s);
262 let raw = after.split('<').next().unwrap_or("");
263 unescape_entities(raw)
264 .split_whitespace()
265 .collect::<Vec<_>>()
266 .join(" ")
267}
268
269fn decode_uddg(href: &str) -> String {
272 let normalized = href.replace("&", "&");
273 if let Some(idx) = normalized.find("uddg=") {
274 let rest = &normalized[idx + 5..];
275 let enc = rest.split('&').next().unwrap_or("");
276 return percent_decode(enc);
277 }
278 if let Some(stripped) = normalized.strip_prefix("//") {
279 return format!("https://{stripped}");
280 }
281 normalized
282}
283
284fn percent_decode(s: &str) -> String {
286 let bytes = s.as_bytes();
287 let mut out = Vec::with_capacity(bytes.len());
288 let mut i = 0;
289 while i < bytes.len() {
290 match bytes[i] {
291 b'%' if i + 2 < bytes.len() => {
292 let hi = (bytes[i + 1] as char).to_digit(16);
293 let lo = (bytes[i + 2] as char).to_digit(16);
294 if let (Some(hi), Some(lo)) = (hi, lo) {
295 out.push((hi * 16 + lo) as u8);
296 i += 3;
297 continue;
298 }
299 out.push(bytes[i]);
300 i += 1;
301 }
302 b'+' => {
303 out.push(b' ');
304 i += 1;
305 }
306 b => {
307 out.push(b);
308 i += 1;
309 }
310 }
311 }
312 String::from_utf8_lossy(&out).into_owned()
313}
314
315fn unescape_entities(s: &str) -> String {
317 s.replace("&", "&")
318 .replace("<", "<")
319 .replace(">", ">")
320 .replace(""", "\"")
321 .replace("'", "'")
322 .replace("'", "'")
323}
324
325fn collect_topics(topics: &[Value], out: &mut Vec<Value>, max: usize) {
328 for t in topics {
329 if out.len() >= max {
330 return;
331 }
332 if let Some(sub) = t.get("Topics").and_then(Value::as_array) {
333 collect_topics(sub, out, max);
334 continue;
335 }
336 let text = t.get("Text").and_then(Value::as_str).unwrap_or("");
337 let url = t.get("FirstURL").and_then(Value::as_str).unwrap_or("");
338 if text.is_empty() && url.is_empty() {
339 continue;
340 }
341 let title = text.split(" - ").next().unwrap_or(text);
342 out.push(json!({ "title": title, "url": url, "snippet": text }));
343 }
344}
345
346fn urlencode(s: &str) -> String {
349 let mut out = String::with_capacity(s.len());
350 for b in s.bytes() {
351 match b {
352 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
353 out.push(b as char)
354 }
355 _ => out.push_str(&format!("%{b:02X}")),
356 }
357 }
358 out
359}
360
361#[async_trait]
362impl ToolExecutor for NetTools {
363 async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
364 match tool {
365 "http_request" => self.http_request(params).await,
366 "web_search" => self.web_search(params).await,
367 other => Err(format!("unknown tool: '{other}'")),
370 }
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn head_truncates_on_char_boundary() {
380 let s = "ééé"; let t = head(s, 3);
382 assert!(t.starts_with('é') && t.ends_with("…[truncated]…"));
383 }
384
385 #[test]
386 fn urlencode_escapes_spaces_and_specials() {
387 assert_eq!(urlencode("a b&c"), "a%20b%26c");
388 assert_eq!(urlencode("plain-text_1.0~"), "plain-text_1.0~");
389 }
390
391 #[test]
392 fn net_tool_defs_advertises_both() {
393 let defs = net_tool_defs();
394 let http = defs
395 .iter()
396 .find(|d| d["name"] == "http_request")
397 .expect("http_request def");
398 assert_eq!(http["mutating"], true);
399 assert_eq!(http["tier"], "full_access");
400 let search = defs
401 .iter()
402 .find(|d| d["name"] == "web_search")
403 .expect("web_search def");
404 assert_eq!(search["tier"], "full_access");
405 assert!(search.get("mutating").is_none());
406 }
407
408 #[tokio::test]
409 async fn http_request_rejects_non_http_url() {
410 let nt = NetTools::new();
411 let err = nt
412 .execute("http_request", &json!({ "url": "file:///etc/passwd" }))
413 .await
414 .unwrap_err();
415 assert!(err.contains("absolute http(s)"), "{err}");
416 }
417
418 #[tokio::test]
419 async fn unknown_tool_falls_through() {
420 let nt = NetTools::new();
421 let err = nt.execute("teleport", &json!({})).await.unwrap_err();
422 assert!(err.starts_with("unknown tool"), "{err}");
423 }
424
425 #[test]
426 fn percent_decode_handles_encoded_urls() {
427 assert_eq!(
428 percent_decode("https%3A%2F%2Fexample.com%2Fa%20b"),
429 "https://example.com/a b"
430 );
431 }
432
433 #[test]
434 fn decode_uddg_extracts_destination() {
435 let href = "//duckduckgo.com/l/?uddg=https%3A%2F%2Frust-lang.org%2F&rut=abc";
436 assert_eq!(decode_uddg(href), "https://rust-lang.org/");
437 }
438
439 #[test]
440 fn parse_ddg_html_extracts_results() {
441 let html = r##"
443 <div class="result">
444 <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>
445 <a class="result__snippet" href="#">Documentation for the Rust standard library.</a>
446 </div>
447 <div class="result">
448 <a class="result__a" href="//duckduckgo.com/l/?uddg=https%3A%2F%2Fcrates.io%2F">crates.io</a>
449 <a class="result__snippet" href="#">The Rust package registry.</a>
450 </div>
451 "##;
452 let results = parse_ddg_html(html, 5);
453 assert_eq!(results.len(), 2);
454 assert_eq!(results[0]["url"], "https://doc.rust-lang.org/std/");
455 assert_eq!(results[0]["title"], "The Rust Standard Library");
456 assert!(results[0]["snippet"]
457 .as_str()
458 .unwrap()
459 .contains("standard library"));
460 assert_eq!(results[1]["url"], "https://crates.io/");
461 assert_eq!(parse_ddg_html(html, 1).len(), 1);
463 }
464}