use super::config::BrowseConfig;
use super::engine::BrowserEngine;
use super::helpers;
use super::tab_guard::TabGuard;
use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError, ToolExecutionMode};
use async_trait::async_trait;
use parking_lot::Mutex;
use serde_json::{json, Value};
use std::sync::Arc;
use tokio::sync::oneshot;
pub struct BrowseTool {
engine: Arc<dyn BrowserEngine>,
config: BrowseConfig,
pending_callback: Mutex<Option<crate::tools::ProgressCallback>>,
tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
}
impl BrowseTool {
pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
Self {
engine,
config: BrowseConfig::default(),
pending_callback: Mutex::new(None),
tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
}
}
pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
Self {
engine,
config,
pending_callback: Mutex::new(None),
tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
}
}
}
#[async_trait]
impl AgentTool for BrowseTool {
fn name(&self) -> &str {
"browse"
}
fn label(&self) -> &str {
"Browse"
}
fn description(&self) -> &str {
"Browse a web page with a built-in headless browser. Renders JavaScript-powered \
pages and returns content as markdown (default), html, or links. Use when \
web_search results are insufficient and you need to read the actual page content. \
Supports waiting for dynamic content via CSS selectors."
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to browse"
},
"format": {
"type": "string",
"enum": ["markdown", "html", "text", "links"],
"default": "markdown",
"description": "Output format: markdown (default), html, plain text, or list of links"
},
"selector": {
"type": "string",
"description": "CSS selector to extract only matching elements"
},
"wait_for": {
"type": "string",
"description": "CSS selector to wait for before extracting (for JS-rendered content)"
},
"screenshot": {
"type": "boolean",
"default": false,
"description": "Include a PNG screenshot as an image block"
}
},
"required": ["url"]
})
}
fn on_progress(&self, callback: crate::tools::ProgressCallback) {
*self.pending_callback.lock() = Some(callback);
}
fn execution_mode(&self) -> ToolExecutionMode {
ToolExecutionMode::SequentialOnly
}
fn current_tab_id(&self) -> Option<uuid::Uuid> {
*self.tab_id_slot.lock().lock()
}
fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
*self.tab_id_slot.lock() = slot;
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<oneshot::Receiver<()>>,
_ctx: &ToolContext,
) -> Result<AgentToolResult, ToolError> {
let url = params["url"]
.as_str()
.ok_or_else(|| "Missing required parameter: url".to_string())?;
let format = params["format"].as_str().unwrap_or("markdown");
let selector = params["selector"].as_str();
let wait_for = params["wait_for"].as_str();
let want_screenshot = params["screenshot"].as_bool().unwrap_or(false);
tracing::info!(url = %url, format = %format, "browsing page");
let raw_tab = self
.engine
.new_tab()
.await
.map_err(|e| format!("Failed to open browser tab: {}", e))?;
let tab_id = raw_tab.tab_id();
*self.tab_id_slot.lock().lock() = Some(tab_id);
if let Some(cb) = self.pending_callback.lock().take() {
#[cfg(feature = "native-browser")]
{
use super::oxibrowser_backend::OxiTab;
if let Some(oxi_tab) = raw_tab.as_any().downcast_ref::<OxiTab>() {
oxi_tab.set_progress_callback(cb);
}
}
#[cfg(not(feature = "native-browser"))]
{
let _ = cb; }
}
let guard = TabGuard::new(raw_tab);
let tab = guard.tab();
let page = tab
.goto(url)
.await
.map_err(|e| format!("Navigation failed: {}", e))?;
if let Some(sel) = wait_for {
tab.wait_for(sel, self.config.default_wait_timeout_ms)
.await
.map_err(|e| format!("wait_for '{}' failed: {}", sel, e))?;
}
let output = match format {
"html" => {
if let Some(sel) = selector {
tab.query_all(sel)
.await
.map_err(|e| e.to_string())?
.join("\n\n")
} else {
page.html.clone()
}
}
"links" => {
let links = helpers::extract_links(tab).await?;
helpers::format_links(&links)
}
"text" => {
if let Some(sel) = selector {
tab.query_all(sel)
.await
.map_err(|e| e.to_string())?
.join("\n")
} else {
page.markdown.clone()
}
}
_ => {
if let Some(sel) = selector {
tab.query_all(sel)
.await
.map_err(|e| e.to_string())?
.join("\n\n")
} else {
page.markdown.clone()
}
}
};
let title = page.title.clone();
let final_url = page.url.clone();
let status = page.status;
let screenshot_blocks = if want_screenshot {
match tab.screenshot(self.config.screenshot_width).await {
Ok(png) => {
let b64 =
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
let img =
oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(b64, "image/png"));
Some(vec![img])
}
Err(e) => {
tracing::warn!("screenshot failed for {}: {}", final_url, e);
None
}
}
} else {
None
};
guard.close().await;
*self.tab_id_slot.lock().lock() = None;
let mut result = AgentToolResult::success(output).with_metadata(json!({
"url": final_url,
"title": title,
"status": status,
}));
if let Some(blocks) = screenshot_blocks {
result = result.with_content_blocks(blocks);
}
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::browse::engine::{BrowserError, BrowserTab};
use async_trait::async_trait;
struct MockEngine;
#[async_trait]
impl BrowserEngine for MockEngine {
async fn new_tab(&self) -> Result<Box<dyn BrowserTab>, BrowserError> {
Err(BrowserError::Backend("MockEngine: no real browser".into()))
}
async fn close(&self) -> Result<(), BrowserError> {
Ok(())
}
async fn is_alive(&self) -> bool {
false
}
}
#[test]
fn browse_tool_is_sequential_only() {
let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
assert!(matches!(
tool.execution_mode(),
crate::tools::ToolExecutionMode::SequentialOnly
));
}
#[test]
fn browse_tool_tab_id_slot_receives_id_from_agent_loop() {
let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
assert!(tool.current_tab_id().is_none());
let slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>> =
Arc::new(parking_lot::Mutex::new(None));
tool.set_tab_id_slot(Arc::clone(&slot));
let tab_id = uuid::Uuid::new_v4();
*slot.lock() = Some(tab_id);
assert_eq!(tool.current_tab_id(), Some(tab_id));
*slot.lock() = None;
assert!(tool.current_tab_id().is_none());
}
#[test]
fn browse_tool_on_progress_stores_pending_callback() {
let tool = BrowseTool::new(std::sync::Arc::new(MockEngine));
let called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let called_clone = Arc::clone(&called);
tool.on_progress(oxi_ai::progress_callback(move |_: String| {
called_clone.store(true, std::sync::atomic::Ordering::SeqCst);
}));
let pending = tool.pending_callback.lock();
assert!(
pending.is_some(),
"pending_callback should be set after on_progress"
);
}
}