lash_tools/web/
fetch_url.rs1use 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
9pub 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
33pub 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}