1use std::collections::VecDeque;
2use std::sync::{Arc, Mutex};
3use std::time::Instant;
4
5use thiserror::Error;
6
7use swink_agent::AgentEvent;
8use swink_agent::{AgentTool, Plugin, PostTurnPolicy, PreDispatchPolicy};
9
10use crate::config::{SearchProviderKind, WebPluginConfig, WebPluginConfigBuilder};
11use crate::domain::DomainFilter;
12use crate::playwright::{PlaywrightBridge, Viewport};
13use crate::policy::{ContentSanitizerPolicy, DomainFilterPolicy, RateLimitPolicy};
14use crate::search::SearchProvider;
15use crate::tools::{ExtractTool, FetchTool, ScreenshotTool, SearchTool};
16
17#[derive(Debug, Error)]
22pub enum WebPluginError {
23 #[error("failed to build HTTP client: {0}")]
26 HttpClient(#[from] reqwest::Error),
27
28 #[error(
31 "search provider `{provider}` requires the `{feature}` feature to be enabled at compile time"
32 )]
33 SearchProviderFeatureDisabled {
34 provider: &'static str,
35 feature: &'static str,
36 },
37
38 #[error("search provider `{provider}` requires an API key but none was configured")]
41 MissingApiKey { provider: &'static str },
42}
43
44pub struct WebPlugin {
49 config: WebPluginConfig,
50 http_client: reqwest::Client,
51 search_provider: Arc<dyn SearchProvider>,
52 playwright_bridge: Arc<tokio::sync::Mutex<Option<PlaywrightBridge>>>,
53 rate_state: Arc<Mutex<VecDeque<Instant>>>,
54}
55
56impl WebPlugin {
57 pub fn new() -> Result<Self, WebPluginError> {
63 Self::from_config(WebPluginConfig::default())
64 }
65
66 #[must_use]
68 pub fn builder() -> WebPluginConfigBuilder {
69 WebPluginConfigBuilder::new()
70 }
71
72 pub fn from_config(config: WebPluginConfig) -> Result<Self, WebPluginError> {
78 let http_client = reqwest::Client::builder()
79 .user_agent(&config.user_agent)
80 .redirect(reqwest::redirect::Policy::limited(
81 config.max_redirects as usize,
82 ))
83 .timeout(config.request_timeout)
84 .build()?;
85
86 let search_provider = build_search_provider(&config, &http_client)?;
87
88 Ok(Self {
89 config,
90 http_client,
91 search_provider,
92 playwright_bridge: Arc::new(tokio::sync::Mutex::new(None)),
93 rate_state: Arc::new(Mutex::new(VecDeque::new())),
94 })
95 }
96}
97
98#[allow(unused_variables)]
99fn build_search_provider(
100 config: &WebPluginConfig,
101 http_client: &reqwest::Client,
102) -> Result<Arc<dyn SearchProvider>, WebPluginError> {
103 match &config.search_provider_kind {
104 #[cfg(feature = "duckduckgo")]
105 SearchProviderKind::DuckDuckGo => Ok(Arc::new(crate::search::DuckDuckGoProvider::new(
106 http_client.clone(),
107 ))),
108 #[cfg(not(feature = "duckduckgo"))]
109 SearchProviderKind::DuckDuckGo => Err(WebPluginError::SearchProviderFeatureDisabled {
110 provider: "duckduckgo",
111 feature: "duckduckgo",
112 }),
113 #[cfg(feature = "brave")]
114 SearchProviderKind::Brave => {
115 let key = config
116 .brave_api_key
117 .clone()
118 .ok_or(WebPluginError::MissingApiKey { provider: "brave" })?;
119 Ok(Arc::new(crate::search::BraveProvider::new(
120 key,
121 http_client.clone(),
122 )))
123 }
124 #[cfg(not(feature = "brave"))]
125 SearchProviderKind::Brave => Err(WebPluginError::SearchProviderFeatureDisabled {
126 provider: "brave",
127 feature: "brave",
128 }),
129 #[cfg(feature = "tavily")]
130 SearchProviderKind::Tavily => {
131 let key = config
132 .tavily_api_key
133 .clone()
134 .ok_or(WebPluginError::MissingApiKey { provider: "tavily" })?;
135 Ok(Arc::new(crate::search::TavilyProvider::new(
136 key,
137 http_client.clone(),
138 )))
139 }
140 #[cfg(not(feature = "tavily"))]
141 SearchProviderKind::Tavily => Err(WebPluginError::SearchProviderFeatureDisabled {
142 provider: "tavily",
143 feature: "tavily",
144 }),
145 }
146}
147
148impl Plugin for WebPlugin {
149 fn name(&self) -> &str {
150 "web"
151 }
152
153 fn tools(&self) -> Vec<Arc<dyn AgentTool>> {
154 vec![
155 Arc::new(FetchTool::new(
156 self.http_client.clone(),
157 self.config.max_content_length,
158 self.config.request_timeout,
159 )),
160 Arc::new(SearchTool::new(
161 self.search_provider.clone(),
162 self.config.max_search_results,
163 )),
164 Arc::new(ScreenshotTool::new(
165 self.playwright_bridge.clone(),
166 self.config.playwright_path.clone(),
167 Viewport {
168 width: self.config.viewport_width,
169 height: self.config.viewport_height,
170 },
171 self.config.screenshot_timeout,
172 )),
173 Arc::new(ExtractTool::new(
174 self.playwright_bridge.clone(),
175 self.config.playwright_path.clone(),
176 self.config.screenshot_timeout,
177 )),
178 ]
179 }
180
181 fn pre_dispatch_policies(&self) -> Vec<Arc<dyn PreDispatchPolicy>> {
182 let domain_filter = DomainFilter {
183 allowlist: self.config.domain_allowlist.clone(),
184 denylist: self.config.domain_denylist.clone(),
185 block_private_ips: self.config.block_private_ips,
186 };
187 vec![
188 Arc::new(DomainFilterPolicy::new(domain_filter)),
189 Arc::new(RateLimitPolicy::new(
190 self.rate_state.clone(),
191 self.config.rate_limit_rpm,
192 )),
193 ]
194 }
195
196 fn post_turn_policies(&self) -> Vec<Arc<dyn PostTurnPolicy>> {
197 if self.config.sanitizer_enabled {
198 vec![Arc::new(ContentSanitizerPolicy::new())]
199 } else {
200 vec![]
201 }
202 }
203
204 fn on_event(&self, event: &AgentEvent) {
205 match classify_web_event(event) {
206 WebEventClass::Start(name) => {
207 tracing::info!(tool = %name, "Web tool execution started");
208 }
209 WebEventClass::Error(name) => {
210 tracing::warn!(tool = %name, "Web tool execution completed with error");
211 }
212 WebEventClass::Ignored => {}
213 }
214 }
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
222enum WebEventClass<'a> {
223 Start(&'a str),
225 Error(&'a str),
227 Ignored,
229}
230
231fn classify_web_event(event: &AgentEvent) -> WebEventClass<'_> {
232 match event {
233 AgentEvent::ToolExecutionStart { name, .. } if name.starts_with("web.") => {
234 WebEventClass::Start(name.as_str())
235 }
236 AgentEvent::ToolExecutionEnd { name, is_error, .. }
237 if *is_error && name.starts_with("web.") =>
238 {
239 WebEventClass::Error(name.as_str())
240 }
241 _ => WebEventClass::Ignored,
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn new_with_default_config_succeeds_when_default_provider_feature_enabled() {
251 assert!(WebPlugin::new().is_ok(), "default construction failed");
253 }
254
255 #[cfg(feature = "brave")]
256 #[test]
257 fn brave_without_api_key_returns_missing_api_key_error() {
258 let config = WebPluginConfig {
259 search_provider_kind: SearchProviderKind::Brave,
260 brave_api_key: None,
261 ..WebPluginConfig::default()
262 };
263 match WebPlugin::from_config(config) {
264 Err(WebPluginError::MissingApiKey { provider: "brave" }) => {}
265 Err(other) => panic!("unexpected error: {other:?}"),
266 Ok(_) => panic!("expected missing API key error, got Ok"),
267 }
268 }
269
270 #[cfg(feature = "tavily")]
271 #[test]
272 fn tavily_without_api_key_returns_missing_api_key_error() {
273 let config = WebPluginConfig {
274 search_provider_kind: SearchProviderKind::Tavily,
275 tavily_api_key: None,
276 ..WebPluginConfig::default()
277 };
278 match WebPlugin::from_config(config) {
279 Err(WebPluginError::MissingApiKey { provider: "tavily" }) => {}
280 Err(other) => panic!("unexpected error: {other:?}"),
281 Ok(_) => panic!("expected missing API key error, got Ok"),
282 }
283 }
284
285 #[cfg(feature = "brave")]
286 #[test]
287 fn brave_with_api_key_constructs_successfully() {
288 let config = WebPluginConfig {
289 search_provider_kind: SearchProviderKind::Brave,
290 brave_api_key: Some("test-key".to_string()),
291 ..WebPluginConfig::default()
292 };
293 assert!(WebPlugin::from_config(config).is_ok());
294 }
295}
296
297#[cfg(test)]
298mod on_event_tests {
299 use super::{WebEventClass, classify_web_event};
300 use serde_json::json;
301 use swink_agent::AgentEvent;
302 use swink_agent::AgentToolResult;
303
304 fn tool_end(name: &str, is_error: bool) -> AgentEvent {
305 AgentEvent::ToolExecutionEnd {
306 id: "tc1".into(),
307 name: name.into(),
308 result: if is_error {
309 AgentToolResult::error("boom")
310 } else {
311 AgentToolResult::text("ok")
312 },
313 is_error,
314 }
315 }
316
317 fn tool_start(name: &str) -> AgentEvent {
318 AgentEvent::ToolExecutionStart {
319 id: "tc1".into(),
320 name: name.into(),
321 arguments: json!({}),
322 }
323 }
324
325 #[test]
326 fn non_web_tool_error_is_not_attributed_to_web_plugin() {
327 assert_eq!(
331 classify_web_event(&tool_end("bash.run", true)),
332 WebEventClass::Ignored
333 );
334 assert_eq!(
335 classify_web_event(&tool_end("unrelated_tool", true)),
336 WebEventClass::Ignored
337 );
338 }
339
340 #[test]
341 fn web_tool_error_is_attributed_to_web_plugin() {
342 assert_eq!(
343 classify_web_event(&tool_end("web.fetch", true)),
344 WebEventClass::Error("web.fetch")
345 );
346 }
347
348 #[test]
349 fn successful_web_tool_end_is_ignored() {
350 assert_eq!(
351 classify_web_event(&tool_end("web.fetch", false)),
352 WebEventClass::Ignored
353 );
354 }
355
356 #[test]
357 fn web_tool_start_is_classified() {
358 assert_eq!(
359 classify_web_event(&tool_start("web.search")),
360 WebEventClass::Start("web.search")
361 );
362 assert_eq!(
363 classify_web_event(&tool_start("bash.run")),
364 WebEventClass::Ignored
365 );
366 }
367}