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