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