Skip to main content

web_retrieval/
types.rs

1//! Input/output types for web tools.
2
3use std::fmt::Write;
4
5use agentic_tools_core::fmt::TextFormat;
6use agentic_tools_core::fmt::TextOptions;
7use chrono::DateTime;
8use chrono::Utc;
9use schemars::JsonSchema;
10use serde::Deserialize;
11use serde::Serialize;
12
13// ============================================================================
14// web_fetch types
15// ============================================================================
16
17/// Input for the `web_fetch` tool.
18#[derive(Debug, Clone, Deserialize, JsonSchema)]
19pub struct WebFetchInput {
20    /// The URL to fetch
21    pub url: String,
22    /// Whether to generate a Haiku summary (default: false).
23    /// Requires Anthropic credentials when enabled.
24    #[serde(default)]
25    pub summarize: bool,
26    /// Maximum bytes to download (default: 5MB, hard limit: 20MB)
27    #[serde(default)]
28    pub max_bytes: Option<usize>,
29}
30
31/// Output from the `web_fetch` tool.
32#[derive(Debug, Clone, Serialize, JsonSchema)]
33pub struct WebFetchOutput {
34    /// The final URL after redirects
35    pub final_url: String,
36    /// Page title (extracted from HTML if available)
37    pub title: Option<String>,
38    /// Content-Type header value
39    pub content_type: String,
40    /// Approximate word count of the content
41    pub word_count: usize,
42    /// Whether the content was truncated due to size limits
43    pub truncated: bool,
44    /// When the page was retrieved
45    pub retrieved_at: DateTime<Utc>,
46    /// The converted content (markdown for HTML, raw for text, pretty-printed for JSON)
47    pub content: String,
48    /// Optional Haiku summary (only present when summarize=true)
49    pub summary: Option<String>,
50}
51
52impl TextFormat for WebFetchOutput {
53    fn fmt_text(&self, _opts: &TextOptions) -> String {
54        let mut out = String::new();
55        let _ = writeln!(out, "URL: {}", self.final_url);
56        if let Some(title) = &self.title {
57            let _ = writeln!(out, "Title: {title}");
58        }
59        let _ = write!(
60            out,
61            "Retrieved: {} | Words: {}",
62            self.retrieved_at.format("%Y-%m-%d %H:%M UTC"),
63            self.word_count
64        );
65        if self.truncated {
66            out.push_str(" | TRUNCATED");
67        }
68        out.push('\n');
69        if let Some(summary) = &self.summary {
70            out.push_str("\n--- Summary ---\n");
71            out.push_str(summary);
72            out.push('\n');
73        }
74        out.push_str("\n--- Content ---\n");
75        out.push_str(&self.content);
76        out
77    }
78}
79
80// ============================================================================
81// web_search types
82// ============================================================================
83
84/// Input for the `web_search` tool.
85#[derive(Debug, Clone, Deserialize, JsonSchema)]
86pub struct WebSearchInput {
87    /// Search query. Use a natural-language question or description;
88    /// Exa is semantic/neural search — do NOT use keyword-stuffed queries.
89    pub query: String,
90    /// Number of results to return (default: 8, max: 20)
91    #[serde(default)]
92    pub num_results: Option<u32>,
93}
94
95/// Output from the `web_search` tool.
96#[derive(Debug, Clone, Serialize, JsonSchema)]
97pub struct WebSearchOutput {
98    /// The original search query
99    pub query: String,
100    /// When the search was performed
101    pub retrieved_at: DateTime<Utc>,
102    /// Trimmed orientation context from Exa (if available)
103    pub context: Option<String>,
104    /// Compact, citable result cards
105    pub results: Vec<WebSearchResultCard>,
106}
107
108/// A single result card from web search.
109#[derive(Debug, Clone, Serialize, JsonSchema)]
110pub struct WebSearchResultCard {
111    /// URL of the result
112    pub url: String,
113    /// Domain extracted from URL
114    pub domain: String,
115    /// Page title
116    pub title: Option<String>,
117    /// Published date (if available)
118    pub published_date: Option<String>,
119    /// Author (if available)
120    pub author: Option<String>,
121    /// Relevance score (0-100)
122    pub score: Option<u32>,
123    /// Short snippet (up to 300 chars) from highlights or summary
124    pub snippet: Option<String>,
125}
126
127impl TextFormat for WebSearchOutput {
128    fn fmt_text(&self, _opts: &TextOptions) -> String {
129        let mut out = String::new();
130        let _ = writeln!(out, "Query: {}", self.query);
131        let _ = writeln!(
132            out,
133            "Retrieved: {}",
134            self.retrieved_at.format("%Y-%m-%d %H:%M UTC")
135        );
136
137        if let Some(ctx) = &self.context {
138            let _ = write!(out, "\n--- Context ---\n{ctx}\n");
139        }
140
141        let _ = write!(out, "\n--- Results ({}) ---\n", self.results.len());
142        for (i, card) in self.results.iter().enumerate() {
143            let _ = write!(
144                out,
145                "\n{}. {} ({})\n   {}\n",
146                i + 1,
147                card.title.as_deref().unwrap_or("(untitled)"),
148                card.domain,
149                card.url,
150            );
151            let mut meta = Vec::new();
152            if let Some(date) = &card.published_date {
153                meta.push(format!("Date: {date}"));
154            }
155            if let Some(author) = &card.author {
156                meta.push(format!("Author: {author}"));
157            }
158            if !meta.is_empty() {
159                let _ = writeln!(out, "   {}", meta.join(" | "));
160            }
161            if let Some(score) = card.score {
162                let _ = writeln!(out, "   Score: {score}/100");
163            }
164            if let Some(snippet) = &card.snippet {
165                let _ = writeln!(out, "   {snippet}");
166            }
167        }
168        out
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn author_displayed_without_date() {
178        let output = WebSearchOutput {
179            query: "test query".into(),
180            retrieved_at: Utc::now(),
181            context: None,
182            results: vec![WebSearchResultCard {
183                url: "https://example.com".into(),
184                domain: "example.com".into(),
185                title: Some("Test Title".into()),
186                published_date: None,            // No date
187                author: Some("Jane Doe".into()), // Has author
188                score: None,
189                snippet: None,
190            }],
191        };
192
193        let text = output.fmt_text(&TextOptions::default());
194        assert!(
195            text.contains("Author: Jane Doe"),
196            "Author should be displayed even when date is missing"
197        );
198    }
199
200    #[test]
201    fn date_and_author_displayed_together() {
202        let output = WebSearchOutput {
203            query: "test query".into(),
204            retrieved_at: Utc::now(),
205            context: None,
206            results: vec![WebSearchResultCard {
207                url: "https://example.com".into(),
208                domain: "example.com".into(),
209                title: Some("Test Title".into()),
210                published_date: Some("2025-01-15".into()),
211                author: Some("John Smith".into()),
212                score: None,
213                snippet: None,
214            }],
215        };
216
217        let text = output.fmt_text(&TextOptions::default());
218        assert!(
219            text.contains("Date: 2025-01-15 | Author: John Smith"),
220            "Date and author should be joined with pipe"
221        );
222    }
223
224    #[test]
225    fn date_displayed_without_author() {
226        let output = WebSearchOutput {
227            query: "test query".into(),
228            retrieved_at: Utc::now(),
229            context: None,
230            results: vec![WebSearchResultCard {
231                url: "https://example.com".into(),
232                domain: "example.com".into(),
233                title: Some("Test Title".into()),
234                published_date: Some("2025-01-15".into()),
235                author: None,
236                score: None,
237                snippet: None,
238            }],
239        };
240
241        let text = output.fmt_text(&TextOptions::default());
242        assert!(
243            text.contains("Date: 2025-01-15"),
244            "Date should be displayed when author is missing"
245        );
246        assert!(
247            !text.contains("Author:"),
248            "Author should not appear when not present"
249        );
250    }
251}