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