tkach 0.5.0

Provider-independent Rust agent runtime — streaming, reasoning summaries, prompt caching, and per-call approval gating.
Documentation
use async_trait::async_trait;
use serde_json::{Value, json};

use crate::error::ToolError;
use crate::tool::{Tool, ToolClass, ToolContext, ToolOutput};

/// Fetch content from a URL.
pub struct WebFetch;

#[async_trait]
impl Tool for WebFetch {
    fn name(&self) -> &str {
        "web_fetch"
    }

    fn class(&self) -> ToolClass {
        ToolClass::ReadOnly
    }

    fn description(&self) -> &str {
        "Fetch content from a URL. Returns the response body as text. \
         For HTML pages, returns raw HTML (consider using with an LLM to extract info)."
    }

    fn input_schema(&self) -> Value {
        json!({
            "type": "object",
            "properties": {
                "url": {
                    "type": "string",
                    "description": "The URL to fetch"
                },
                "headers": {
                    "type": "object",
                    "description": "Optional HTTP headers as key-value pairs",
                    "additionalProperties": { "type": "string" }
                }
            },
            "required": ["url"]
        })
    }

    async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolOutput, ToolError> {
        let url = input["url"]
            .as_str()
            .ok_or_else(|| ToolError::InvalidInput("url is required".into()))?;

        let client = reqwest::Client::builder()
            .timeout(std::time::Duration::from_secs(30))
            .build()
            .map_err(|e| ToolError::Execution(format!("failed to create HTTP client: {e}")))?;

        let mut request = client.get(url);

        if let Some(headers) = input["headers"].as_object() {
            for (key, value) in headers {
                if let Some(v) = value.as_str() {
                    request = request.header(key, v);
                }
            }
        }

        // Dropping the in-flight send() future aborts the connection in
        // reqwest/hyper, so select! on cancel gives us a prompt abort.
        let response = tokio::select! {
            biased;
            _ = ctx.cancel.cancelled() => return Err(ToolError::Cancelled),
            resp = request.send() => resp
                .map_err(|e| ToolError::Execution(format!("HTTP request failed: {e}")))?,
        };

        let status = response.status().as_u16();
        let body = tokio::select! {
            biased;
            _ = ctx.cancel.cancelled() => return Err(ToolError::Cancelled),
            body = response.text() => body
                .map_err(|e| ToolError::Execution(format!("failed to read response body: {e}")))?,
        };

        if status >= 400 {
            Ok(ToolOutput::error(format!("HTTP {status}\n{body}")))
        } else {
            // Truncate very large responses
            let max_len = 100_000;
            if body.len() > max_len {
                Ok(ToolOutput::text(format!(
                    "{}\n\n[truncated at {max_len} chars, total: {} chars]",
                    &body[..max_len],
                    body.len()
                )))
            } else {
                Ok(ToolOutput::text(body))
            }
        }
    }
}