Skip to main content

oxi_agent/tools/browse/
browse_tool.rs

1//! Browse tool — render a web page and return its content.
2//!
3//! Opens exactly **one** tab per request and extracts all content from it.
4//! Never calls engine-level methods that would open additional tabs.
5
6use super::config::BrowseConfig;
7use super::engine::BrowserEngine;
8use super::helpers;
9use super::tab_guard::TabGuard;
10use crate::tools::typed::TypedTool;
11use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
12use async_trait::async_trait;
13use parking_lot::Mutex;
14use schemars::JsonSchema;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::sync::Arc;
18use tokio::sync::oneshot;
19
20/// Typed arguments for [`BrowseTool`].
21#[derive(Deserialize, JsonSchema)]
22pub struct BrowseArgs {
23    url: String,
24    #[serde(default = "default_format")]
25    format: String,
26    selector: Option<String>,
27    #[serde(rename = "waitFor")]
28    wait_for: Option<String>,
29    #[serde(default)]
30    screenshot: bool,
31}
32
33fn default_format() -> String {
34    "markdown".to_string()
35}
36
37/// Render a web page using the built-in headless browser.
38///
39/// Returns page content as markdown, html, text, or a list of links.
40pub struct BrowseTool {
41    engine: Arc<dyn BrowserEngine>,
42    config: BrowseConfig,
43    /// Shared callback management (progress + browse progress).
44    callbacks: super::callback_mixin::BrowseCallbacks,
45    /// Shared slot for the current tab's ID. The agent loop creates the slot
46    /// and passes it via `set_tab_id_slot`; BrowseTool writes `Some(tab_id)`
47    /// when it opens a tab and `None` on close.
48    tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
49}
50
51impl BrowseTool {
52    /// Create with the given engine and default config.
53    pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
54        Self {
55            engine,
56            config: BrowseConfig::default(),
57            callbacks: super::callback_mixin::BrowseCallbacks::new(),
58            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
59        }
60    }
61
62    /// Create with custom configuration.
63    pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
64        Self {
65            engine,
66            config,
67            callbacks: super::callback_mixin::BrowseCallbacks::new(),
68            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
69        }
70    }
71}
72
73#[async_trait]
74impl AgentTool for BrowseTool {
75    fn name(&self) -> &str {
76        "browse"
77    }
78
79    fn label(&self) -> &str {
80        "Browse"
81    }
82
83    fn description(&self) -> &str {
84        "Browse a web page with a built-in headless browser. Renders JavaScript-powered \
85         pages and returns content as markdown (default), html, or links. Use when \
86         web_search results are insufficient and you need to read the actual page content. \
87         Supports waiting for dynamic content via CSS selectors."
88    }
89
90    fn parameters_schema(&self) -> Value {
91        json!({
92            "type": "object",
93            "properties": {
94                "url": {
95                    "type": "string",
96                    "description": "URL to browse"
97                },
98                "format": {
99                    "type": "string",
100                    "enum": ["markdown", "html", "text", "links"],
101                    "default": "markdown",
102                    "description": "Output format: markdown (default), html, plain text, or list of links"
103                },
104                "selector": {
105                    "type": "string",
106                    "description": "CSS selector to extract only matching elements"
107                },
108                "wait_for": {
109                    "type": "string",
110                    "description": "CSS selector to wait for before extracting (for JS-rendered content)"
111                },
112                "screenshot": {
113                    "type": "boolean",
114                    "default": false,
115                    "description": "Include a PNG screenshot as an image block"
116                }
117            },
118            "required": ["url"]
119        })
120    }
121
122    fn on_progress(&self, callback: crate::tools::ProgressCallback) {
123        self.callbacks.store_progress(callback);
124    }
125
126    fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
127        self.callbacks.store_browse(callback);
128    }
129
130    /// Sequential execution preserved for stability.
131    ///
132    /// Per-tab routing via `TabCallbackRegistry` now correctly routes
133    /// progress events by `tab_id`, making parallel execution safe.
134    /// However, `SequentialOnly` is kept for now unless a concrete
135    /// multi-tab use case requires parallel browse calls.
136    fn execution_mode(&self) -> ToolExecutionMode {
137        ToolExecutionMode::SequentialOnly
138    }
139
140    fn current_tab_id(&self) -> Option<uuid::Uuid> {
141        *self.tab_id_slot.lock().lock()
142    }
143
144    fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
145        *self.tab_id_slot.lock() = slot;
146    }
147
148    async fn execute(
149        &self,
150        _tool_call_id: &str,
151        params: Value,
152        _signal: Option<oneshot::Receiver<()>>,
153        _ctx: &ToolContext,
154    ) -> Result<AgentToolResult, ToolError> {
155        let args: BrowseArgs =
156            serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
157        self.execute_typed(_tool_call_id, args, _signal, _ctx).await
158    }
159}
160
161#[async_trait]
162impl TypedTool for BrowseTool {
163    type Args = BrowseArgs;
164
165    async fn execute_typed(
166        &self,
167        _tool_call_id: &str,
168        args: Self::Args,
169        _signal: Option<oneshot::Receiver<()>>,
170        _ctx: &ToolContext,
171    ) -> Result<AgentToolResult, ToolError> {
172        let url = &args.url;
173        let format = &args.format;
174        let selector = args.selector.as_deref();
175        let wait_for = args.wait_for.as_deref();
176        let want_screenshot = args.screenshot;
177
178        tracing::info!(url = %url, format = %format, "browsing page");
179
180        let raw_tab = self
181            .engine
182            .new_tab()
183            .await
184            .map_err(|e| format!("Failed to open browser tab: {}", e))?;
185        let tab_id = raw_tab.tab_id();
186        *self.tab_id_slot.lock().lock() = Some(tab_id);
187        self.callbacks.register_on_tab(raw_tab.as_ref());
188        let guard = TabGuard::new(raw_tab);
189        let tab = guard.tab();
190
191        let page = tab
192            .goto(url)
193            .await
194            .map_err(|e| format!("Navigation failed: {}", e))?;
195        if let Some(sel) = wait_for {
196            tab.wait_for(sel, self.config.default_wait_timeout_ms)
197                .await
198                .map_err(|e| format!("wait_for '{}' failed: {}", sel, e))?;
199        }
200
201        let output = match format.as_str() {
202            "html" => {
203                if let Some(sel) = selector {
204                    tab.query_all(sel)
205                        .await
206                        .map_err(|e| e.to_string())?
207                        .join("\n\n")
208                } else {
209                    page.html.clone()
210                }
211            }
212            "links" => {
213                let links = helpers::extract_links(tab).await?;
214                helpers::format_links(&links)
215            }
216            "text" => {
217                if let Some(sel) = selector {
218                    tab.query_all(sel)
219                        .await
220                        .map_err(|e| e.to_string())?
221                        .join("\n")
222                } else {
223                    page.markdown.clone()
224                }
225            }
226            _ => {
227                if let Some(sel) = selector {
228                    tab.query_all(sel)
229                        .await
230                        .map_err(|e| e.to_string())?
231                        .join("\n\n")
232                } else {
233                    page.markdown.clone()
234                }
235            }
236        };
237
238        let title = page.title.clone();
239        let final_url = page.url.clone();
240        let status = page.status;
241
242        let screenshot_blocks = if want_screenshot {
243            match tab.screenshot(self.config.screenshot_width).await {
244                Ok(png) => {
245                    let b64 =
246                        base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
247                    let img =
248                        oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(b64, "image/png"));
249                    Some(vec![img])
250                }
251                Err(e) => {
252                    tracing::warn!("screenshot failed for {}: {}", final_url, e);
253                    None
254                }
255            }
256        } else {
257            None
258        };
259
260        guard.close().await;
261        *self.tab_id_slot.lock().lock() = None;
262
263        let mut result = AgentToolResult::success(output).with_metadata(json!({
264            "url": final_url,
265            "title": title,
266            "status": status,
267        }));
268
269        if let Some(blocks) = screenshot_blocks {
270            result = result.with_content_blocks(blocks);
271        }
272
273        Ok(result)
274    }
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use crate::tools::browse::engine::{BrowserError, BrowserTab};
281
282    /// Minimal `BrowserEngine` stub. We never call `new_tab` in the test,
283    /// so the trait methods are allowed to return `Err` — the goal is just
284    /// to be able to construct a `BrowseTool` and read `execution_mode()`.
285    struct MockEngine;
286
287    #[async_trait]
288    impl BrowserEngine for MockEngine {
289        async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError> {
290            Err(BrowserError::Backend("MockEngine: no real browser".into()))
291        }
292
293        async fn close(&self) -> Result<(), BrowserError> {
294            Ok(())
295        }
296
297        async fn is_alive(&self) -> bool {
298            false
299        }
300    }
301
302    #[test]
303    fn browse_tool_is_sequential_only() {
304        let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
305        assert!(matches!(
306            tool.execution_mode(),
307            crate::tools::ToolExecutionMode::SequentialOnly
308        ));
309    }
310
311    #[test]
312    fn browse_tool_tab_id_slot_receives_id_from_agent_loop() {
313        // Simulate the agent loop's flow: set_tab_id_slot → write tab_id →
314        // read current_tab_id → clear.
315        let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
316
317        // Initially no tab_id
318        assert!(tool.current_tab_id().is_none());
319
320        // Agent loop creates a slot and passes it
321        let slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
322            Arc::new(parking_lot::Mutex::new(None));
323        tool.set_tab_id_slot(Arc::clone(&slot));
324
325        // Simulate BrowseTool::execute opening a tab
326        let tab_id = uuid::Uuid::new_v4();
327        *slot.lock() = Some(tab_id);
328
329        // Agent loop's progress callback reads the slot
330        assert_eq!(tool.current_tab_id(), Some(tab_id));
331
332        // BrowseTool::execute closes the tab
333        *slot.lock() = None;
334        assert!(tool.current_tab_id().is_none());
335    }
336}