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 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
79/// Host-side executor for `http_request` + `web_search`.
80pub 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        // Primary: DuckDuckGo's HTML endpoint returns a real result list
162        // (title/url/snippet). No key, no extra deps — we parse the anchors and
163        // decode the `uddg` redirect to the destination URL.
164        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        // Fallback: the instant-answer JSON API (stable, but sparse — abstracts
172        // and related topics only).
173        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
222/// Extract up to `max` `{title, url, snippet}` results from a DuckDuckGo HTML
223/// page. Deliberately dependency-free string parsing: find each
224/// `class="result__a"` anchor, decode its `uddg=` redirect to the destination
225/// URL, take the anchor text as the title, and the following `result__snippet`
226/// as the snippet. Robust to markup drift in the sense that a missed field just
227/// yields fewer/emptier results, never a panic.
228fn 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
251/// The value of the first `href="..."`-style attribute starting at `marker`.
252fn 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
258/// Text between the first `>` and the next `<`, with HTML tags/entities
259/// stripped and whitespace collapsed. Best-effort.
260fn 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
269/// Decode a DuckDuckGo result href into the destination URL. Results wrap the
270/// target in `/l/?uddg=<percent-encoded-url>`; other hrefs pass through.
271fn decode_uddg(href: &str) -> String {
272    let normalized = href.replace("&amp;", "&");
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
284/// Minimal percent-decoding (`%XX` → byte, `+` → space).
285fn 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
315/// Unescape the handful of HTML entities DDG emits in titles/snippets.
316fn unescape_entities(s: &str) -> String {
317    s.replace("&amp;", "&")
318        .replace("&lt;", "<")
319        .replace("&gt;", ">")
320        .replace("&quot;", "\"")
321        .replace("&#x27;", "'")
322        .replace("&#39;", "'")
323}
324
325/// Flatten DuckDuckGo's (possibly nested) RelatedTopics into `{title, url,
326/// snippet}` entries, up to `max`.
327fn 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
346/// Minimal percent-encoding for a query string value (RFC 3986 unreserved set
347/// stays literal; everything else is `%XX`). Avoids pulling in a url-encode dep.
348fn 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            // The prefix must be exactly "unknown tool" so any runtime dispatch
368            // falls through instead of hard-erroring.
369            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 = "ééé"; // 2 bytes each
381        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&amp;rut=abc";
436        assert_eq!(decode_uddg(href), "https://rust-lang.org/");
437    }
438
439    #[test]
440    fn parse_ddg_html_extracts_results() {
441        // A trimmed shape of DuckDuckGo's HTML result markup.
442        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&amp;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        // Respects the max cap.
462        assert_eq!(parse_ddg_html(html, 1).len(), 1);
463    }
464}