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