Skip to main content

pi_agent/tools/
web_fetch.rs

1use async_trait::async_trait;
2use serde_json::{json, Value};
3
4use crate::types::{AgentTool, AgentToolResult};
5
6/// Fetch a URL and return a coarse text representation of the response body.
7/// Very intentionally simple: strips HTML tags by removing `<...>` sequences,
8/// collapses whitespace, and truncates to a sensible size. For richer parsing,
9/// upstream callers can install a custom tool.
10pub struct WebFetchTool;
11
12#[async_trait]
13impl AgentTool for WebFetchTool {
14    fn name(&self) -> &str {
15        "web_fetch"
16    }
17    fn description(&self) -> &str {
18        "Fetch a URL via HTTPS and return a text-only excerpt of the response body. Use for documentation pages, GitHub READMEs, status checks, etc."
19    }
20    fn parameters(&self) -> Value {
21        json!({
22            "type": "object",
23            "properties": {
24                "url": {"type": "string", "description": "Absolute URL to fetch"},
25                "max_chars": {"type": "integer", "default": 8000}
26            },
27            "required": ["url"]
28        })
29    }
30    async fn execute(&self, _id: &str, args: Value) -> Result<AgentToolResult, String> {
31        let url = args
32            .get("url")
33            .and_then(|v| v.as_str())
34            .ok_or("missing 'url'")?
35            .to_string();
36        let max_chars = args
37            .get("max_chars")
38            .and_then(|v| v.as_u64())
39            .unwrap_or(8000) as usize;
40
41        let resp = reqwest::Client::new()
42            .get(&url)
43            .header("user-agent", "pi-coding-agent/1.0")
44            .send()
45            .await
46            .map_err(|e| format!("fetch {url}: {e}"))?;
47        let status = resp.status();
48        let text = resp.text().await.map_err(|e| e.to_string())?;
49        let stripped = strip_html(&text);
50        let truncated = if stripped.len() > max_chars {
51            format!(
52                "{}\n...(truncated, {} chars total)",
53                &stripped[..max_chars],
54                stripped.len()
55            )
56        } else {
57            stripped
58        };
59        Ok(AgentToolResult::text(format!(
60            "GET {url} [{status}]\n{truncated}"
61        )))
62    }
63}
64
65fn strip_html(s: &str) -> String {
66    let mut out = String::with_capacity(s.len());
67    let mut in_tag = false;
68    let mut last_ws = false;
69    for ch in s.chars() {
70        match ch {
71            '<' => in_tag = true,
72            '>' => {
73                in_tag = false;
74                if !last_ws {
75                    out.push(' ');
76                    last_ws = true;
77                }
78            }
79            c if in_tag => {
80                let _ = c;
81            }
82            c if c.is_whitespace() => {
83                if !last_ws {
84                    out.push(' ');
85                    last_ws = true;
86                }
87            }
88            c => {
89                out.push(c);
90                last_ws = false;
91            }
92        }
93    }
94    out.trim().to_string()
95}