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