oxi_agent/tools/browse/
browse_tool.rs1use 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#[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
37pub struct BrowseTool {
41 engine: Arc<dyn BrowserEngine>,
42 config: BrowseConfig,
43 callbacks: super::callback_mixin::BrowseCallbacks,
45 tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
49}
50
51impl BrowseTool {
52 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 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 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 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 let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
316
317 assert!(tool.current_tab_id().is_none());
319
320 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 let tab_id = uuid::Uuid::new_v4();
327 *slot.lock() = Some(tab_id);
328
329 assert_eq!(tool.current_tab_id(), Some(tab_id));
331
332 *slot.lock() = None;
334 assert!(tool.current_tab_id().is_none());
335 }
336}