Skip to main content

car_server_core/assistant/
net_tools.rs

1//! Network tools for the assistant: `http_request` and `web_search`.
2//!
3//! These run **in the host process**, not through the bound substrate — the
4//! default sandbox is `--network none`, so (exactly like a coding assistant's
5//! web-fetch) files and shell execute inside the container while network access
6//! is served from the host. They are wired into the [`GeneralExecutor`] as a
7//! delegate ([`car_engine::ToolExecutor`]) so the runtime's validator, policy
8//! engine, and event log still wrap every call.
9//!
10//! [`GeneralExecutor`]: super::executor::GeneralExecutor
11
12use std::time::Duration;
13
14use async_trait::async_trait;
15use car_engine::ToolExecutor;
16use serde_json::{json, Value};
17
18/// Cap on a fetched body / search payload folded back into model context.
19const MAX_BODY_BYTES: usize = 64 * 1024;
20/// Default per-request wall-clock budget.
21const DEFAULT_TIMEOUT_SECS: u64 = 30;
22const MAX_TIMEOUT_SECS: u64 = 120;
23
24/// Keep the first `cap` bytes of `s` on a char boundary, with a marker when
25/// truncated. (HTTP bodies are most useful head-first, unlike shell tails.)
26fn 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
37/// The two network tool schemas, in the model-facing `{name, description,
38/// parameters}` shape. Advertised via `GeneralExecutor::with_delegate`.
39pub 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
80/// Host-side executor for `http_request` + `web_search`.
81pub 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        // Primary: DuckDuckGo's HTML endpoint returns a real result list
167        // (title/url/snippet). No key, no extra deps — we parse the anchors and
168        // decode the `uddg` redirect to the destination URL.
169        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        // Fallback: the instant-answer JSON API (stable, but sparse — abstracts
177        // and related topics only).
178        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
227/// Extract up to `max` `{title, url, snippet}` results from a DuckDuckGo HTML
228/// page. Deliberately dependency-free string parsing: find each
229/// `class="result__a"` anchor, decode its `uddg=` redirect to the destination
230/// URL, take the anchor text as the title, and the following `result__snippet`
231/// as the snippet. Robust to markup drift in the sense that a missed field just
232/// yields fewer/emptier results, never a panic.
233fn 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
268/// The value of the first `href="..."`-style attribute starting at `marker`.
269fn 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
275/// Text between the first `>` and the next `<`, with HTML tags/entities
276/// stripped and whitespace collapsed. Best-effort.
277fn 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
286/// Decode a DuckDuckGo result href into the destination URL. Results wrap the
287/// target in `/l/?uddg=<percent-encoded-url>`; other hrefs pass through.
288fn decode_uddg(href: &str) -> String {
289    let normalized = href.replace("&amp;", "&");
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
301/// Minimal percent-decoding (`%XX` → byte, `+` → space).
302fn 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
332/// Unescape the handful of HTML entities DDG emits in titles/snippets.
333fn unescape_entities(s: &str) -> String {
334    s.replace("&amp;", "&")
335        .replace("&lt;", "<")
336        .replace("&gt;", ">")
337        .replace("&quot;", "\"")
338        .replace("&#x27;", "'")
339        .replace("&#39;", "'")
340}
341
342/// Flatten DuckDuckGo's (possibly nested) RelatedTopics into `{title, url,
343/// snippet}` entries, up to `max`.
344fn 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
363/// Minimal percent-encoding for a query string value (RFC 3986 unreserved set
364/// stays literal; everything else is `%XX`). Avoids pulling in a url-encode dep.
365fn 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            // The prefix must be exactly "unknown tool" so any runtime dispatch
385            // falls through instead of hard-erroring.
386            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 = "ééé"; // 2 bytes each
398        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&amp;rut=abc";
485        assert_eq!(decode_uddg(href), "https://rust-lang.org/");
486    }
487
488    #[test]
489    fn parse_ddg_html_extracts_results() {
490        // A trimmed shape of DuckDuckGo's HTML result markup.
491        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&amp;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        // Respects the max cap.
511        assert_eq!(parse_ddg_html(html, 1).len(), 1);
512    }
513}