Skip to main content

lash_tools/web/
fetch_url.rs

1use serde_json::json;
2
3use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
4
5use lash_tool_support::{StaticToolExecute, StaticToolProvider, object_schema, require_str};
6
7/// Fetch a URL and return its content as text.
8pub struct FetchUrl {
9    api_key: String,
10    client: reqwest::Client,
11}
12
13impl FetchUrl {
14    pub fn new(api_key: impl Into<String>) -> Self {
15        Self {
16            api_key: api_key.into(),
17            client: reqwest::Client::builder()
18                .timeout(std::time::Duration::from_secs(30))
19                .build()
20                .unwrap_or_default(),
21        }
22    }
23}
24
25impl Default for FetchUrl {
26    fn default() -> Self {
27        Self::new("")
28    }
29}
30
31/// Build the cached `fetch_url` tool provider for the given Tavily API key.
32pub fn fetch_url_provider(api_key: impl Into<String>) -> StaticToolProvider<FetchUrl> {
33    StaticToolProvider::new(vec![fetch_url_tool_definition()], FetchUrl::new(api_key))
34}
35
36#[async_trait::async_trait]
37impl StaticToolExecute for FetchUrl {
38    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
39        let args = call.args;
40        let url = match require_str(args, "url") {
41            Ok(s) => s,
42            Err(e) => return e,
43        };
44
45        if self.api_key.trim().is_empty() {
46            return ToolResult::err(json!("Tavily API key is required for web.fetch"));
47        }
48
49        let body = json!({
50            "api_key": self.api_key,
51            "urls": [url],
52        });
53
54        let resp = self
55            .client
56            .post("https://api.tavily.com/extract")
57            .json(&body)
58            .send()
59            .await;
60        let resp = match resp {
61            Ok(resp) => resp,
62            Err(err) => return ToolResult::err(json!(format!("web.fetch request failed: {err}"))),
63        };
64        let status = resp.status();
65        let value: serde_json::Value = match resp.json().await {
66            Ok(value) => value,
67            Err(err) => return ToolResult::err(json!(format!("web.fetch response failed: {err}"))),
68        };
69        if !status.is_success() {
70            return ToolResult::err(value);
71        }
72        let content = value
73            .get("results")
74            .and_then(|value| value.as_array())
75            .and_then(|results| results.first())
76            .and_then(|item| item.get("raw_content").or_else(|| item.get("content")))
77            .and_then(|value| value.as_str())
78            .unwrap_or_default();
79        ToolResult::ok(json!({
80            "url": url,
81            "content": content,
82        }))
83    }
84}
85
86fn fetch_url_tool_definition() -> ToolDefinition {
87    ToolDefinition::raw(
88                "tool:fetch_url",
89                "fetch_url",
90                "Fetch one known URL and extract readable page text.",
91                object_schema(
92                    serde_json::json!({
93                        "url": { "type": "string", "format": "uri" }
94                    }),
95                    &["url"],
96                ),
97                serde_json::json!({
98                    "type": "object",
99                    "properties": {
100                        "url": {
101                            "type": "string",
102                            "description": "Fetched URL."
103                        },
104                        "content": {
105                            "type": "string",
106                            "description": "Extracted readable page text. Empty when no extractable content was returned."
107                        }
108                    },
109                    "required": ["url", "content"],
110                    "additionalProperties": false
111                }),
112            )
113            .with_examples(vec!["await web.fetch({ url: \"https://www.rust-lang.org/\" })?".into()])
114            .with_lashlang_binding(lash_tool_support::lashlang_binding(
115                ["web"],
116                "fetch",
117                &["fetch", "open_url"],
118            ))
119            .with_scheduling(ToolScheduling::Parallel)
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn fetch_url_returns_minimal_typed_record_and_is_showcased() {
128        let definition = fetch_url_tool_definition();
129
130        assert_eq!(
131            definition.contract.output_schema["type"],
132            serde_json::json!("object")
133        );
134        assert_eq!(
135            definition.contract.output_schema["required"],
136            serde_json::json!(["url", "content"])
137        );
138        assert_eq!(
139            definition.contract.output_schema["additionalProperties"],
140            serde_json::json!(false)
141        );
142        assert_eq!(
143            definition.manifest.activation,
144            lash_core::ToolActivation::Always
145        );
146        assert_eq!(
147            definition.manifest.availability.base,
148            lash_core::ToolAvailability::Showcased
149        );
150    }
151}