nexo_core/agent/
web_fetch_tool.rs1use 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 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 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 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 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 cfg.max_bytes = (max as usize).min(cfg.max_bytes);
122 }
123
124 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 let def = WebFetchTool::tool_def();
169 let required = def.parameters["required"].as_array().unwrap();
170 assert!(required.iter().any(|v| v == "urls"));
171 }
172}