lash_tools/web/
fetch_url.rs1use serde_json::json;
2
3use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
4
5use lash_tool_support::{StaticToolExecute, StaticToolProvider, object_schema, require_str};
6
7pub 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
31pub 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}