use serde_json::json;
use lash_core::{ToolCall, ToolDefinition, ToolResult, ToolScheduling};
use lash_tool_support::{StaticToolExecute, StaticToolProvider, object_schema, require_str};
pub struct FetchUrl {
api_key: String,
client: reqwest::Client,
}
impl FetchUrl {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
client: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap_or_default(),
}
}
}
impl Default for FetchUrl {
fn default() -> Self {
Self::new("")
}
}
pub fn fetch_url_provider(api_key: impl Into<String>) -> StaticToolProvider<FetchUrl> {
StaticToolProvider::new(vec![fetch_url_tool_definition()], FetchUrl::new(api_key))
}
#[async_trait::async_trait]
impl StaticToolExecute for FetchUrl {
async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
let args = call.args;
let url = match require_str(args, "url") {
Ok(s) => s,
Err(e) => return e,
};
if self.api_key.trim().is_empty() {
return ToolResult::err(json!("Tavily API key is required for web.fetch"));
}
let body = json!({
"api_key": self.api_key,
"urls": [url],
});
let resp = self
.client
.post("https://api.tavily.com/extract")
.json(&body)
.send()
.await;
let resp = match resp {
Ok(resp) => resp,
Err(err) => return ToolResult::err(json!(format!("web.fetch request failed: {err}"))),
};
let status = resp.status();
let value: serde_json::Value = match resp.json().await {
Ok(value) => value,
Err(err) => return ToolResult::err(json!(format!("web.fetch response failed: {err}"))),
};
if !status.is_success() {
return ToolResult::err(value);
}
let content = value
.get("results")
.and_then(|value| value.as_array())
.and_then(|results| results.first())
.and_then(|item| item.get("raw_content").or_else(|| item.get("content")))
.and_then(|value| value.as_str())
.unwrap_or_default();
ToolResult::ok(json!({
"url": url,
"content": content,
}))
}
}
fn fetch_url_tool_definition() -> ToolDefinition {
ToolDefinition::raw(
"tool:fetch_url",
"fetch_url",
"Fetch one known URL and extract readable page text.",
object_schema(
serde_json::json!({
"url": { "type": "string", "format": "uri" }
}),
&["url"],
),
serde_json::json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "Fetched URL."
},
"content": {
"type": "string",
"description": "Extracted readable page text. Empty when no extractable content was returned."
}
},
"required": ["url", "content"],
"additionalProperties": false
}),
)
.with_examples(vec!["await web.fetch({ url: \"https://www.rust-lang.org/\" })?".into()])
.with_lashlang_binding(lash_tool_support::lashlang_binding(
["web"],
"fetch",
&["fetch", "open_url"],
))
.with_scheduling(ToolScheduling::Parallel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fetch_url_returns_minimal_typed_record_and_is_showcased() {
let definition = fetch_url_tool_definition();
assert_eq!(
definition.contract.output_schema["type"],
serde_json::json!("object")
);
assert_eq!(
definition.contract.output_schema["required"],
serde_json::json!(["url", "content"])
);
assert_eq!(
definition.contract.output_schema["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
definition.manifest.activation,
lash_core::ToolActivation::Always
);
assert_eq!(
definition.manifest.availability.base,
lash_core::ToolAvailability::Showcased
);
}
}