Skip to main content

swink_agent_plugin_web/
plugin.rs

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/// Errors returned when constructing a [`WebPlugin`].
18///
19/// These replace the previous panics on recoverable misconfiguration so that
20/// hosts embedding the plugin can surface a diagnostic instead of aborting.
21#[derive(Debug, Error)]
22pub enum WebPluginError {
23    /// The underlying `reqwest` HTTP client failed to build (e.g. invalid
24    /// TLS backend configuration).
25    #[error("failed to build HTTP client: {0}")]
26    HttpClient(#[from] reqwest::Error),
27
28    /// The configured search provider is not available because the
29    /// corresponding cargo feature is disabled at compile time.
30    #[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    /// The configured search provider requires an API key that was not
39    /// supplied on the configuration.
40    #[error("search provider `{provider}` requires an API key but none was configured")]
41    MissingApiKey { provider: &'static str },
42}
43
44/// Web browsing plugin for swink-agent.
45///
46/// Provides tools for fetching web pages and searching the web, along with
47/// safety policies for domain filtering, rate limiting, and content sanitization.
48pub 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    /// Create a new `WebPlugin` with default configuration.
57    ///
58    /// Returns an error if the default configuration cannot be satisfied
59    /// (for example, if the default search provider's feature flag is
60    /// disabled at compile time).
61    pub fn new() -> Result<Self, WebPluginError> {
62        Self::from_config(WebPluginConfig::default())
63    }
64
65    /// Create a builder for custom configuration.
66    #[must_use]
67    pub fn builder() -> WebPluginConfigBuilder {
68        WebPluginConfigBuilder::new()
69    }
70
71    /// Create a `WebPlugin` from an explicit configuration.
72    ///
73    /// Returns an error if the HTTP client cannot be built or if the
74    /// configured search provider is unavailable (missing feature flag or
75    /// missing API key).
76    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/// Classification of an [`AgentEvent`] from the web plugin's perspective.
228///
229/// Extracted from [`WebPlugin::on_event`] so the namespace-gating logic can be
230/// exercised directly by unit tests without depending on a tracing subscriber.
231#[derive(Debug, Clone, PartialEq, Eq)]
232enum WebEventClass<'a> {
233    /// A `web_*` tool has started executing.
234    Start(&'a str),
235    /// A `web_*` tool has failed.
236    Error(&'a str),
237    /// Not a web-namespaced tool event — plugin should ignore it.
238    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        // The default config uses DuckDuckGo which is enabled by default.
262        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        // Regression for #237: the plugin previously matched every failing
338        // ToolExecutionEnd as a web-tool failure, including tools from other
339        // namespaces (e.g., `bash.run`).
340        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}