Skip to main content

oxi_agent/tools/browse/
browse_extract_tool.rs

1//! Browse extract tool — extract structured data from a web page.
2//!
3//! All extraction is done via the already-loaded tab's JavaScript engine —
4//! no engine-level methods that would open additional tabs.
5
6use super::config::BrowseConfig;
7use super::engine::{BrowserEngine, BrowserError, BrowserTab};
8use super::helpers;
9use super::tab_guard::TabGuard;
10use crate::tools::typed::TypedTool;
11use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
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/// Typed arguments for [`BrowseExtractTool`].
20#[derive(Deserialize, JsonSchema)]
21pub struct BrowseExtractArgs {
22    url: String,
23    selector: String,
24    #[serde(default = "default_extract")]
25    extract: String,
26    #[serde(default = "default_true")]
27    all: bool,
28    #[serde(default = "default_timeout")]
29    timeout: u64,
30}
31
32fn default_extract() -> String {
33    "text".to_string()
34}
35fn default_true() -> bool {
36    true
37}
38fn default_timeout() -> u64 {
39    30
40}
41
42/// Extract structured data from a web page using CSS selectors.
43///
44/// Returns links, text content, or element metadata for all elements
45/// matching the given CSS selector.
46pub struct BrowseExtractTool {
47    engine: Arc<dyn BrowserEngine>,
48    #[allow(dead_code)]
49    config: BrowseConfig,
50    /// Shared callback management (progress + browse progress).
51    callbacks: super::callback_mixin::BrowseCallbacks,
52    /// Shared slot for the current tab's ID.
53    tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
54}
55
56impl BrowseExtractTool {
57    /// Create with the given engine and default config.
58    pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
59        Self {
60            engine,
61            config: BrowseConfig::default(),
62            callbacks: super::callback_mixin::BrowseCallbacks::new(),
63            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
64        }
65    }
66
67    /// Create with custom configuration.
68    pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
69        Self {
70            engine,
71            config,
72            callbacks: super::callback_mixin::BrowseCallbacks::new(),
73            tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
74        }
75    }
76}
77
78#[async_trait]
79impl AgentTool for BrowseExtractTool {
80    fn name(&self) -> &str {
81        "browse_extract"
82    }
83
84    fn label(&self) -> &str {
85        "Extract Page Data"
86    }
87
88    fn description(&self) -> &str {
89        "Extract structured data from a web page: links, text content, or elements matching \
90         a CSS selector. Use when you need specific data from a page rather than the full content. \
91         Supports extracting all matching elements or just the first match."
92    }
93
94    fn on_progress(&self, callback: crate::tools::ProgressCallback) {
95        self.callbacks.store_progress(callback);
96    }
97
98    fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
99        self.callbacks.store_browse(callback);
100    }
101
102    fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
103        *self.tab_id_slot.lock() = slot;
104    }
105
106    fn current_tab_id(&self) -> Option<uuid::Uuid> {
107        *self.tab_id_slot.lock().lock()
108    }
109
110    fn parameters_schema(&self) -> Value {
111        json!({
112            "type": "object",
113            "properties": {
114                "url": {
115                    "type": "string",
116                    "description": "URL of the page to extract from"
117                },
118                "selector": {
119                    "type": "string",
120                    "description": "CSS selector to match elements"
121                },
122                "extract": {
123                    "type": "string",
124                    "enum": ["links", "text", "elements", "markdown"],
125                    "default": "text",
126                    "description": "What to extract: 'links' (href + text), 'text' (textContent), 'elements' (tag + text + attrs), 'markdown' (innerHTML as markdown)"
127                },
128                "all": {
129                    "type": "boolean",
130                    "default": true,
131                    "description": "Return all matches (true) or just the first (false)"
132                },
133                "timeout": {
134                    "type": "integer",
135                    "default": 30,
136                    "description": "Maximum time in seconds"
137                }
138            },
139            "required": ["url", "selector"]
140        })
141    }
142
143    async fn execute(
144        &self,
145        _tool_call_id: &str,
146        params: Value,
147        _signal: Option<oneshot::Receiver<()>>,
148        _ctx: &ToolContext,
149    ) -> Result<AgentToolResult, ToolError> {
150        let args: BrowseExtractArgs =
151            serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
152        self.execute_typed(_tool_call_id, args, _signal, _ctx).await
153    }
154}
155
156#[async_trait]
157impl TypedTool for BrowseExtractTool {
158    type Args = BrowseExtractArgs;
159
160    async fn execute_typed(
161        &self,
162        _tool_call_id: &str,
163        args: Self::Args,
164        _signal: Option<oneshot::Receiver<()>>,
165        _ctx: &ToolContext,
166    ) -> Result<AgentToolResult, ToolError> {
167        let url = &args.url;
168        let selector = &args.selector;
169        let extract = &args.extract;
170        let all = args.all;
171        let timeout_secs = args.timeout;
172
173        tracing::info!(url = %url, selector = %selector, extract = %extract, "extracting page data");
174
175        let output = tokio::time::timeout(
176            std::time::Duration::from_secs(timeout_secs),
177            self.extract_from_new_tab(url, selector, extract, all),
178        )
179        .await
180        .map_err(|_| format!("Extract timed out after {}s", timeout_secs))??;
181
182        Ok(output)
183    }
184}
185
186impl BrowseExtractTool {
187    /// Open a tab, navigate, extract, close — one tab, one flow.
188    async fn extract_from_new_tab(
189        &self,
190        url: &str,
191        selector: &str,
192        extract: &str,
193        all: bool,
194    ) -> Result<AgentToolResult, ToolError> {
195        let raw_tab = self
196            .engine
197            .new_tab()
198            .await
199            .map_err(|e| format!("Failed to open browser tab: {}", e))?;
200
201        // Store tab_id so the agent loop can include it in
202        // ToolExecutionUpdate events.
203        let tab_id = raw_tab.tab_id();
204        *self.tab_id_slot.lock().lock() = Some(tab_id);
205
206        // Register progress callbacks on the tab via the engine's registry.
207        self.callbacks
208            .register_on_registry(tab_id, self.engine.callback_registry().as_ref());
209
210        let guard = TabGuard::new(raw_tab);
211
212        let page = guard
213            .tab()
214            .goto(url)
215            .await
216            .map_err(|e| format!("Navigation failed: {}", e))?;
217
218        let output = extract_from_tab(guard.tab(), selector, extract, all)
219            .await
220            .map_err(|e: BrowserError| e.to_string())?;
221
222        let metadata_url = page.url.clone();
223        let metadata_title = page.title.clone();
224        let result_count = count_extracted_items(&output, extract);
225
226        guard.close().await;
227        *self.tab_id_slot.lock().lock() = None;
228
229        Ok(AgentToolResult::success(output).with_metadata(json!({
230            "url": metadata_url,
231            "title": metadata_title,
232            "selector": selector,
233            "extract": extract,
234            "result_count": result_count,
235        })))
236    }
237}
238
239// ── Extraction logic ──────────────────────────────────────────────────────────
240
241/// Count items in extraction output for metadata.
242fn count_extracted_items(output: &str, extract: &str) -> usize {
243    match extract {
244        "links" | "elements" => {
245            // JSON array output — count top-level array elements.
246            serde_json::from_str::<Vec<serde_json::Value>>(output)
247                .map(|v| v.len())
248                .unwrap_or(0)
249        }
250        _ => {
251            // Text/markdown — count non-empty lines.
252            output.lines().filter(|l| !l.trim().is_empty()).count()
253        }
254    }
255}
256
257async fn extract_from_tab(
258    tab: &dyn BrowserTab,
259    selector: &str,
260    extract: &str,
261    all: bool,
262) -> Result<String, BrowserError> {
263    match extract {
264        "links" => {
265            let js = helpers::js_links_within(selector);
266            let value = tab.evaluate(&js).await?;
267            let links = helpers::parse_link_values(value);
268            let links = if all {
269                links
270            } else {
271                links.into_iter().take(1).collect()
272            };
273            let json_links: Vec<Value> = links
274                .iter()
275                .map(|(t, h)| json!({ "text": t, "href": h }))
276                .collect();
277            Ok(serde_json::to_string_pretty(&json_links).unwrap_or_default())
278        }
279        "elements" => {
280            let js = helpers::js_query_elements(selector);
281            let value = tab.evaluate(&js).await?;
282            let elements = helpers::parse_element_values(value);
283            let elements = if all {
284                elements
285            } else {
286                elements.into_iter().take(1).collect()
287            };
288            let json_elems: Vec<Value> = elements
289                .iter()
290                .map(|(tag, text, attrs)| json!({ "tag": tag, "text": text, "attributes": attrs }))
291                .collect();
292            Ok(serde_json::to_string_pretty(&json_elems).unwrap_or_default())
293        }
294        "markdown" => {
295            let texts = tab.query_all(selector).await?;
296            let texts = if all {
297                texts
298            } else {
299                texts.into_iter().take(1).collect()
300            };
301            Ok(texts.join("\n\n"))
302        }
303        _ => {
304            // "text" (default)
305            let texts = tab.query_all(selector).await?;
306            let texts = if all {
307                texts
308            } else {
309                texts.into_iter().take(1).collect()
310            };
311            Ok(texts.join("\n"))
312        }
313    }
314}