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    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    /// 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        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/// Classification of an [`AgentEvent`] from the web plugin's perspective.
218///
219/// Extracted from [`WebPlugin::on_event`] so the namespace-gating logic can be
220/// exercised directly by unit tests without depending on a tracing subscriber.
221#[derive(Debug, Clone, PartialEq, Eq)]
222enum WebEventClass<'a> {
223    /// A `web.*` tool has started executing.
224    Start(&'a str),
225    /// A `web.*` tool has failed.
226    Error(&'a str),
227    /// Not a web-namespaced tool event — plugin should ignore it.
228    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        // The default config uses DuckDuckGo which is enabled by default.
252        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        // Regression for #237: the plugin previously matched every failing
328        // ToolExecutionEnd as a web-tool failure, including tools from other
329        // namespaces (e.g., `bash.run`).
330        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}