Skip to main content

nexo_core/agent/
web_fetch_tool.rs

1//! `web_fetch` built-in tool.
2//!
3//! Companion to `web_search`: takes one or more URLs the agent
4//! already knows (from a prior `web_search` hit, a user message,
5//! a `link_understanding` summary, …) and returns the cleaned
6//! body text + title for each. Reuses the runtime's existing
7//! `LinkExtractor` so:
8//!
9//! - The fetch budget, deny-host list, max-bytes cap, timeout
10//!   and host blocklist are exactly the same as the auto-link
11//!   pipeline. There's no second copy of the config to drift.
12//! - The LRU cache is shared, so a `web_fetch` of a URL the
13//!   user already pasted earlier in the session is free.
14//! - Telemetry (`nexo_link_understanding_fetch_total`,
15//!   `nexo_link_understanding_cache_total`,
16//!   `nexo_link_understanding_fetch_duration_ms`) covers
17//!   `web_fetch` calls as well — operators don't need a second
18//!   dashboard.
19//!
20//! Distinct from `web_search` because the agent often knows the
21//! URL up front (skill output, RSS poll, calendar attachment)
22//! and would otherwise have to either hallucinate a search
23//! query or shell out to a `fetch-url` extension.
24
25use super::context::AgentContext;
26use super::tool_registry::ToolHandler;
27use async_trait::async_trait;
28use nexo_llm::ToolDef;
29use serde_json::{json, Value};
30
31pub struct WebFetchTool;
32
33impl WebFetchTool {
34    pub fn new() -> Self {
35        Self
36    }
37
38    pub fn tool_def() -> ToolDef {
39        ToolDef {
40            name: "web_fetch".to_string(),
41            description: "Fetch one or more URLs and return their cleaned body text + title. \
42                Use when the agent already knows the URL (from a previous web_search, a user \
43                message, a poller item, etc.). Reuses the link-understanding pipeline's \
44                cache, deny-list, and size caps."
45                .to_string(),
46            parameters: json!({
47                "type": "object",
48                "properties": {
49                    "urls": {
50                        "type": "array",
51                        "items": { "type": "string" },
52                        "description": "URLs to fetch. Up to 5 per call to keep the prompt budget bounded."
53                    },
54                    "max_bytes": {
55                        "type": "integer",
56                        "description": "Per-URL body cap (overrides the policy default; clamped down by the deployment's `link_understanding.max_bytes`)."
57                    }
58                },
59                "required": ["urls"]
60            }),
61        }
62    }
63}
64
65impl Default for WebFetchTool {
66    fn default() -> Self {
67        Self::new()
68    }
69}
70
71#[async_trait]
72impl ToolHandler for WebFetchTool {
73    async fn call(&self, ctx: &AgentContext, args: Value) -> anyhow::Result<Value> {
74        // Reuse the runtime's shared LinkExtractor. If link
75        // understanding isn't wired (e.g. tests, minimal boots),
76        // fail loud — we don't want to silently return empty
77        // bodies and let the LLM hallucinate around them.
78        let Some(extractor) = ctx.link_extractor.as_ref() else {
79            return Err(anyhow::anyhow!(
80                "web_fetch unavailable: runtime has no link_understanding extractor wired"
81            ));
82        };
83
84        // Parse args. `urls` is required; `max_bytes` is optional.
85        let urls: Vec<String> = args
86            .get("urls")
87            .and_then(|v| v.as_array())
88            .map(|arr| {
89                arr.iter()
90                    .filter_map(|v| v.as_str().map(str::to_string))
91                    .collect()
92            })
93            .ok_or_else(|| anyhow::anyhow!("web_fetch: `urls` must be a non-empty string array"))?;
94
95        if urls.is_empty() {
96            return Err(anyhow::anyhow!("web_fetch: `urls` is empty"));
97        }
98
99        // Per-call cap so a runaway agent can't queue 1000 fetches.
100        const MAX_URLS_PER_CALL: usize = 5;
101        let urls = if urls.len() > MAX_URLS_PER_CALL {
102            tracing::warn!(
103                requested = urls.len(),
104                cap = MAX_URLS_PER_CALL,
105                "web_fetch: trimming urls list to per-call cap"
106            );
107            urls.into_iter().take(MAX_URLS_PER_CALL).collect()
108        } else {
109            urls
110        };
111
112        // Build cfg from policy. Same shape `web_search`'s expand
113        // path uses (`policy_link_cfg` lives in
114        // `web_search_tool.rs` but we duplicate the 3-line builder
115        // here to keep the modules independent — copying is cheaper
116        // than a cross-module helper for a 3-LOC fn).
117        let mut cfg = ctx.effective_policy().link_understanding.clone();
118        cfg.enabled = true;
119        if let Some(max) = args.get("max_bytes").and_then(|v| v.as_u64()) {
120            // Caller can shrink, never grow past the deployment cap.
121            cfg.max_bytes = (max as usize).min(cfg.max_bytes);
122        }
123
124        // Fetch concurrently. Order preserved so the agent can
125        // correlate each entry to its URL.
126        let mut out: Vec<Value> = Vec::with_capacity(urls.len());
127        for url in &urls {
128            match extractor.fetch(url, &cfg).await {
129                Some(summary) => out.push(json!({
130                    "url": url,
131                    "title": summary.title,
132                    "body": summary.body,
133                    "ok": true,
134                })),
135                None => out.push(json!({
136                    "url": url,
137                    "ok": false,
138                    "reason": "fetch failed (host blocked, timeout, non-HTML, oversized, or transport error). \
139                               Check `nexo_link_understanding_fetch_total{result}` for the bucket.",
140                })),
141            }
142        }
143
144        Ok(json!({
145            "results": out,
146            "count": urls.len(),
147        }))
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn tool_def_shape() {
157        let def = WebFetchTool::tool_def();
158        assert_eq!(def.name, "web_fetch");
159        let params = &def.parameters;
160        assert_eq!(params["type"], "object");
161        assert!(params["properties"]["urls"].is_object());
162        assert_eq!(params["required"][0], "urls");
163    }
164
165    #[test]
166    fn rejects_empty_urls_array() {
167        // Sanity that the JSON Schema marks `urls` as required.
168        let def = WebFetchTool::tool_def();
169        let required = def.parameters["required"].as_array().unwrap();
170        assert!(required.iter().any(|v| v == "urls"));
171    }
172}