1use super::config::BrowseConfig;
8use super::engine::{BrowserEngine, BrowserError};
9use super::helpers;
10use super::tab_guard::TabGuard;
11
12use crate::tools::typed::TypedTool;
13use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
14use async_trait::async_trait;
15use parking_lot::Mutex as SyncMutex;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use std::sync::Arc;
20use std::time::Instant;
21use tokio::sync::{Mutex, oneshot};
22#[derive(Deserialize, Serialize, JsonSchema)]
24pub struct BrowseSessionArgs {
25 action: String,
26 url: Option<String>,
27 selector: Option<String>,
28 value: Option<String>,
29 combo: Option<String>,
30 #[serde(default = "default_pixels")]
31 pixels: u64,
32 javascript: Option<String>,
33 #[serde(default = "default_format")]
34 format: String,
35 #[serde(default = "default_timeout_ms")]
36 timeout_ms: u64,
37 #[serde(default = "default_wait_condition")]
38 wait_condition: String,
39 #[serde(rename = "fromSelector")]
40 from_selector: Option<String>,
41 #[serde(rename = "toSelector")]
42 to_selector: Option<String>,
43 #[serde(rename = "filePath")]
44 file_path: Option<String>,
45 #[serde(default = "default_width")]
46 width: u64,
47}
48
49fn default_pixels() -> u64 {
50 300
51}
52fn default_format() -> String {
53 "markdown".to_string()
54}
55fn default_timeout_ms() -> u64 {
56 10000
57}
58fn default_wait_condition() -> String {
59 "network_idle".to_string()
60}
61fn default_width() -> u64 {
62 800
63}
64
65pub struct BrowseSessionTool {
71 engine: Arc<dyn BrowserEngine>,
72 tab: Arc<Mutex<Option<TabGuard>>>,
73 config: BrowseConfig,
74 last_action: Arc<Mutex<Option<Instant>>>,
75 callbacks: super::callback_mixin::BrowseCallbacks,
77 tab_id_slot: SyncMutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
81}
82
83impl BrowseSessionTool {
84 pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
86 Self {
87 engine,
88 tab: Arc::new(Mutex::new(None)),
89 config: BrowseConfig::default(),
90 last_action: Arc::new(Mutex::new(None)),
91 callbacks: super::callback_mixin::BrowseCallbacks::new(),
92 tab_id_slot: SyncMutex::new(Arc::new(parking_lot::Mutex::new(None))),
93 }
94 }
95
96 pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
98 Self {
99 engine,
100 tab: Arc::new(Mutex::new(None)),
101 config,
102 last_action: Arc::new(Mutex::new(None)),
103 callbacks: super::callback_mixin::BrowseCallbacks::new(),
104 tab_id_slot: SyncMutex::new(Arc::new(parking_lot::Mutex::new(None))),
105 }
106 }
107
108 async fn touch(&self) {
110 *self.last_action.lock().await = Some(Instant::now());
111 }
112
113 async fn check_idle_timeout(&self) -> Result<(), ToolError> {
116 if self.config.session_idle_timeout_secs == 0 {
117 return Ok(());
118 }
119 let elapsed = {
120 let last = self.last_action.lock().await;
121 match *last {
122 Some(instant) => instant.elapsed().as_secs(),
123 None => return Ok(()), }
125 };
126 if elapsed >= self.config.session_idle_timeout_secs {
127 let mut slot = self.tab.lock().await;
129 if let Some(guard) = slot.take() {
130 tracing::warn!(
131 elapsed_secs = elapsed,
132 timeout_secs = self.config.session_idle_timeout_secs,
133 "browse_session: auto-closing stale session"
134 );
135 guard.close().await;
136 *self.tab_id_slot.lock().lock() = None;
138 }
139 return Err(format!(
140 "Session timed out after {}s of inactivity",
141 elapsed
142 ));
143 }
144 Ok(())
145 }
146}
147
148#[async_trait]
149impl AgentTool for BrowseSessionTool {
150 fn name(&self) -> &str {
151 "browse_session"
152 }
153
154 fn label(&self) -> &str {
155 "Browser Session"
156 }
157
158 fn description(&self) -> &str {
159 "Interactive browser session with a persistent tab across calls. \
160 Open a session, perform multiple operations, then close when done. \
161 The tab retains cookies, localStorage, and DOM state between actions. \
162 Use for multi-step interactions like form filling, login flows, and \
163 SPA exploration where reasoning is needed between steps."
164 }
165
166 fn on_progress(&self, callback: crate::tools::ProgressCallback) {
167 let tab_id = self.current_tab_id();
171 if let Some(tid) = tab_id {
172 self.callbacks.store_progress(callback);
173 self.callbacks
174 .register_progress_on_registry(tid, self.engine.callback_registry().as_ref());
175 } else {
176 self.callbacks.store_progress(callback);
177 }
178 }
179
180 fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
181 let tab_id = self.current_tab_id();
182 if let Some(tid) = tab_id {
183 self.callbacks.store_browse(callback);
184 self.callbacks
185 .register_browse_on_registry(tid, self.engine.callback_registry().as_ref());
186 } else {
187 self.callbacks.store_browse(callback);
188 }
189 }
190
191 fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
192 *self.tab_id_slot.lock() = slot;
193 }
194
195 fn current_tab_id(&self) -> Option<uuid::Uuid> {
196 *self.tab_id_slot.lock().lock()
197 }
198
199 fn parameters_schema(&self) -> Value {
200 json!({
201 "type": "object",
202 "properties": {
203 "action": {
204 "type": "string",
205 "enum": [
206 "open",
207 "goto",
208 "back",
209 "forward",
210 "reload",
211 "click",
212 "fill",
213 "type",
214 "clear",
215 "press",
216 "select",
217 "check",
218 "uncheck",
219 "scroll",
220 "scroll_into_view",
221 "hover",
222 "double_click",
223 "right_click",
224 "drag",
225 "upload_file",
226 "wait_for",
227 "wait",
228 "observe",
229 "content",
230 "query_all",
231 "extract_links",
232 "evaluate",
233 "evaluate_await",
234 "get_value",
235 "screenshot",
236 "close"
237 ],
238 "description": "Session action to perform"
239 },
240 "url": {
241 "type": "string",
242 "description": "URL to navigate to (goto action)"
243 },
244 "selector": {
245 "type": "string",
246 "description": "CSS selector (click, fill, type, clear, select, check, uncheck, wait_for, query_all, extract_links)"
247 },
248 "value": {
249 "type": "string",
250 "description": "Value to fill/type/select (fill, type, select actions)"
251 },
252 "combo": {
253 "type": "string",
254 "description": "Key combo (press action, e.g. 'Enter', 'Control+a')"
255 },
256 "pixels": {
257 "type": "integer",
258 "description": "Scroll distance in pixels (scroll action, positive = down)"
259 },
260 "javascript": {
261 "type": "string",
262 "description": "JS expression to evaluate (evaluate, evaluate_await actions)"
263 },
264 "format": {
265 "type": "string",
266 "enum": ["markdown", "html", "text", "links"],
267 "default": "markdown",
268 "description": "Output format for content action"
269 },
270 "timeout_ms": {
271 "type": "integer",
272 "default": 10000,
273 "description": "Timeout in ms (wait_for action)"
274 },
275 "wait_condition": {
276 "type": "string",
277 "enum": ["network_idle", "dom_content_loaded", "load"],
278 "default": "network_idle",
279 "description": "Structured wait condition (wait action): network_idle, dom_content_loaded, or load"
280 },
281 "from_selector": {
282 "type": "string",
283 "description": "Source CSS selector (drag action)"
284 },
285 "to_selector": {
286 "type": "string",
287 "description": "Target CSS selector (drag action)"
288 },
289 "file_path": {
290 "type": "string",
291 "description": "Local file path to upload (upload_file action)"
292 },
293 "width": {
294 "type": "integer",
295 "default": 800,
296 "description": "Viewport width for screenshot (default: 800)"
297 }
298 },
299 "required": ["action"]
300 })
301 }
302
303 #[allow(clippy::too_many_lines)]
304 async fn execute(
305 &self,
306 _tool_call_id: &str,
307 params: Value,
308 _signal: Option<oneshot::Receiver<()>>,
309 _ctx: &ToolContext,
310 ) -> Result<AgentToolResult, ToolError> {
311 let args: BrowseSessionArgs =
312 serde_json::from_value(params).map_err(|e| format!("invalid params: {e}"))?;
313 self.execute_typed(_tool_call_id, args, _signal, _ctx).await
314 }
315}
316
317#[async_trait]
318impl TypedTool for BrowseSessionTool {
319 type Args = BrowseSessionArgs;
320
321 #[allow(clippy::too_many_lines)]
322 async fn execute_typed(
323 &self,
324 _tool_call_id: &str,
325 args: Self::Args,
326 _signal: Option<oneshot::Receiver<()>>,
327 _ctx: &ToolContext,
328 ) -> Result<AgentToolResult, ToolError> {
329 let params = serde_json::to_value(&args).map_err(|e| format!("serialize: {e}"))?;
330 self.dispatch_action(params).await
331 }
332}
333
334impl BrowseSessionTool {
335 #[allow(clippy::too_many_lines)]
338 async fn dispatch_action(&self, params: Value) -> Result<AgentToolResult, ToolError> {
339 let action = params["action"]
340 .as_str()
341 .ok_or_else(|| "Missing required parameter: action".to_string())?;
342
343 let url = params["url"].as_str();
344 let selector = params["selector"].as_str();
345 let value = params["value"].as_str();
346 let combo = params["combo"].as_str();
347 let pixels = params["pixels"].as_u64().unwrap_or(300);
348 let javascript = params["javascript"].as_str();
349 let format = params["format"].as_str().unwrap_or("markdown");
350 let timeout_ms = params["timeout_ms"]
351 .as_u64()
352 .unwrap_or(self.config.default_wait_timeout_ms);
353 let width = params["width"]
354 .as_u64()
355 .unwrap_or(self.config.screenshot_width as u64) as u32;
356 let from_selector = params["from_selector"].as_str();
357 let to_selector = params["to_selector"].as_str();
358 let file_path = params["file_path"].as_str();
359
360 tracing::info!(action = %action, "browse_session action");
361
362 self.touch().await;
363
364 match action {
365 "open" => {
367 let mut slot = self.tab.lock().await;
368 if let Some(old_guard) = slot.take() {
369 tracing::warn!("browse_session: closing previous session on re-open");
370 old_guard.close().await;
371 }
372 let raw_tab = self
373 .engine
374 .new_tab()
375 .await
376 .map_err(|e| format!("Failed to open browser tab: {}", e))?;
377
378 let tab_id = raw_tab.tab_id();
379 *self.tab_id_slot.lock().lock() = Some(tab_id);
380
381 self.callbacks
382 .register_on_registry(tab_id, self.engine.callback_registry().as_ref());
383
384 let guard = TabGuard::new(raw_tab);
385 *slot = Some(guard);
386 Ok(json_ok())
387 }
388
389 "close" => {
390 let mut slot = self.tab.lock().await;
391 match slot.take() {
392 Some(guard) => {
393 guard.close().await;
394 *self.tab_id_slot.lock().lock() = None;
395 Ok(json_ok())
396 }
397 None => Ok(json_error("no active session to close")),
398 }
399 }
400
401 "goto" => {
403 self.check_idle_timeout().await?;
404 let url = url.ok_or_else(|| "Missing required parameter: url".to_string())?;
405 let slot = self.tab.lock().await;
406 let tab = require_tab(&slot)?;
407 let page = tab.goto(url).await.map_err(browser_err)?;
408 Ok(AgentToolResult::success(json_str(&json!({
409 "status": "ok",
410 "url": page.url,
411 "title": page.title,
412 "status_code": page.status,
413 }))))
414 }
415
416 "back" => {
417 self.check_idle_timeout().await?;
418 let slot = self.tab.lock().await;
419 let tab = require_tab(&slot)?;
420 let _ = tab.evaluate("history.back()").await;
421 let page = tab.content().await.map_err(browser_err)?;
422 Ok(AgentToolResult::success(json_str(&json!({
423 "status": "ok",
424 "url": page.url,
425 "title": page.title,
426 }))))
427 }
428
429 "forward" => {
430 self.check_idle_timeout().await?;
431 let slot = self.tab.lock().await;
432 let tab = require_tab(&slot)?;
433 let _ = tab.evaluate("history.forward()").await;
434 let page = tab.content().await.map_err(browser_err)?;
435 Ok(AgentToolResult::success(json_str(&json!({
436 "status": "ok",
437 "url": page.url,
438 "title": page.title,
439 }))))
440 }
441
442 "reload" => {
443 self.check_idle_timeout().await?;
444 let slot = self.tab.lock().await;
445 let tab = require_tab(&slot)?;
446 let _ = tab.evaluate("location.reload()").await;
447 let page = tab.content().await.map_err(browser_err)?;
448 Ok(AgentToolResult::success(json_str(&json!({
449 "status": "ok",
450 "url": page.url,
451 "title": page.title,
452 }))))
453 }
454
455 "click" => {
457 self.check_idle_timeout().await?;
458 let sel =
459 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
460 let slot = self.tab.lock().await;
461 let tab = require_tab(&slot)?;
462 tab.click(sel).await.map_err(browser_err)?;
463 Ok(json_ok())
464 }
465
466 "fill" => {
467 self.check_idle_timeout().await?;
468 let sel =
469 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
470 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
471 let slot = self.tab.lock().await;
472 let tab = require_tab(&slot)?;
473 tab.fill(sel, val).await.map_err(browser_err)?;
474 Ok(json_ok())
475 }
476
477 "type" => {
478 self.check_idle_timeout().await?;
479 let sel =
480 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
481 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
482 let slot = self.tab.lock().await;
483 let tab = require_tab(&slot)?;
484 tab.type_(sel, val).await.map_err(browser_err)?;
485 Ok(json_ok())
486 }
487
488 "clear" => {
489 self.check_idle_timeout().await?;
490 let sel =
491 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
492 let slot = self.tab.lock().await;
493 let tab = require_tab(&slot)?;
494 tab.clear(sel).await.map_err(browser_err)?;
495 Ok(json_ok())
496 }
497
498 "press" => {
499 self.check_idle_timeout().await?;
500 let c = combo.ok_or_else(|| "Missing required parameter: combo".to_string())?;
501 let slot = self.tab.lock().await;
502 let tab = require_tab(&slot)?;
503 tab.press(c).await.map_err(browser_err)?;
504 Ok(json_ok())
505 }
506
507 "select" => {
508 self.check_idle_timeout().await?;
509 let sel =
510 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
511 let val = value.ok_or_else(|| "Missing required parameter: value".to_string())?;
512 let slot = self.tab.lock().await;
513 let tab = require_tab(&slot)?;
514 tab.select_option(sel, val).await.map_err(browser_err)?;
515 Ok(json_ok())
516 }
517
518 "check" => {
519 self.check_idle_timeout().await?;
520 let sel =
521 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
522 let slot = self.tab.lock().await;
523 let tab = require_tab(&slot)?;
524 tab.check(sel).await.map_err(browser_err)?;
525 Ok(json_ok())
526 }
527
528 "uncheck" => {
529 self.check_idle_timeout().await?;
530 let sel =
531 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
532 let slot = self.tab.lock().await;
533 let tab = require_tab(&slot)?;
534 tab.uncheck(sel).await.map_err(browser_err)?;
535 Ok(json_ok())
536 }
537
538 "scroll" => {
539 self.check_idle_timeout().await?;
540 let slot = self.tab.lock().await;
541 let tab = require_tab(&slot)?;
542 tab.scroll(0.0, pixels as f64).await.map_err(browser_err)?;
543 Ok(json_ok())
544 }
545
546 "wait_for" => {
548 self.check_idle_timeout().await?;
549 let sel =
550 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
551 let slot = self.tab.lock().await;
552 let tab = require_tab(&slot)?;
553 tab.wait_for(sel, timeout_ms).await.map_err(browser_err)?;
554 Ok(json_ok())
555 }
556
557 "wait" => {
558 self.check_idle_timeout().await?;
559 let slot = self.tab.lock().await;
560 let tab = require_tab(&slot)?;
561 let cond = match params["wait_condition"].as_str().unwrap_or("network_idle") {
562 "dom_content_loaded" => super::engine::BrowseWaitCondition::DomContentLoaded,
563 "load" => super::engine::BrowseWaitCondition::Load,
564 _ => super::engine::BrowseWaitCondition::NetworkIdle,
565 };
566 tab.wait_for_condition(&cond, timeout_ms)
567 .await
568 .map_err(browser_err)?;
569 Ok(json_ok())
570 }
571
572 "observe" => {
574 self.check_idle_timeout().await?;
575 let slot = self.tab.lock().await;
576 let tab = require_tab(&slot)?;
577 let obs = tab.observe().await.map_err(browser_err)?;
578 Ok(AgentToolResult::success(
579 serde_json::to_string_pretty(&obs).unwrap_or_default(),
580 ))
581 }
582
583 "content" => {
585 self.check_idle_timeout().await?;
586 let slot = self.tab.lock().await;
587 let tab = require_tab(&slot)?;
588 let page = tab.content().await.map_err(browser_err)?;
589
590 let content = match format {
591 "html" => {
592 if let Some(sel) = selector {
593 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
594 } else {
595 page.html.clone()
596 }
597 }
598 "links" => {
599 let links = if let Some(sel) = selector {
600 let js = helpers::js_links_within(sel);
601 let value = tab.evaluate(&js).await.map_err(browser_err)?;
602 helpers::parse_link_values(value)
603 } else {
604 helpers::extract_links(tab)
605 .await
606 .map_err(|e: ToolError| e)?
607 };
608 helpers::format_links(&links)
609 }
610 "text" => {
611 if let Some(sel) = selector {
612 tab.query_all(sel).await.map_err(browser_err)?.join("\n")
613 } else {
614 page.markdown.clone()
615 }
616 }
617 _ => {
618 if let Some(sel) = selector {
619 tab.query_all(sel).await.map_err(browser_err)?.join("\n\n")
620 } else {
621 page.markdown.clone()
622 }
623 }
624 };
625
626 Ok(AgentToolResult::success(json_str(&json!({
627 "status": "ok",
628 "url": page.url,
629 "title": page.title,
630 "content": content,
631 }))))
632 }
633
634 "query_all" => {
635 self.check_idle_timeout().await?;
636 let sel =
637 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
638 let slot = self.tab.lock().await;
639 let tab = require_tab(&slot)?;
640 let results = tab.query_all(sel).await.map_err(browser_err)?;
641 Ok(AgentToolResult::success(json_str(&json!({
642 "status": "ok",
643 "results": results,
644 }))))
645 }
646
647 "extract_links" => {
648 self.check_idle_timeout().await?;
649 let slot = self.tab.lock().await;
650 let tab = require_tab(&slot)?;
651
652 let links = if let Some(sel) = selector {
653 let js = helpers::js_links_within(sel);
654 let value = tab.evaluate(&js).await.map_err(browser_err)?;
655 helpers::parse_link_values(value)
656 } else {
657 helpers::extract_links(tab)
658 .await
659 .map_err(|e: ToolError| e)?
660 };
661
662 let json_links: Vec<Value> = links
663 .iter()
664 .map(|(text, href)| json!({ "text": text, "href": href }))
665 .collect();
666
667 Ok(AgentToolResult::success(json_str(&json!({
668 "status": "ok",
669 "links": json_links,
670 }))))
671 }
672
673 "evaluate" => {
675 self.check_idle_timeout().await?;
676 let js = javascript
677 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
678 let slot = self.tab.lock().await;
679 let tab = require_tab(&slot)?;
680 let result_val = tab.evaluate(js).await.map_err(browser_err)?;
681 Ok(AgentToolResult::success(json_str(&json!({
682 "status": "ok",
683 "result": result_val,
684 }))))
685 }
686
687 "evaluate_await" => {
688 self.check_idle_timeout().await?;
689 let js = javascript
690 .ok_or_else(|| "Missing required parameter: javascript".to_string())?;
691 let slot = self.tab.lock().await;
692 let tab = require_tab(&slot)?;
693 let result_val = tab.evaluate_await(js).await.map_err(browser_err)?;
694 Ok(AgentToolResult::success(json_str(&json!({
695 "status": "ok",
696 "result": result_val,
697 }))))
698 }
699
700 "screenshot" => {
702 self.check_idle_timeout().await?;
703 let slot = self.tab.lock().await;
704 let tab = require_tab(&slot)?;
705 let png = tab.screenshot(width).await.map_err(browser_err)?;
706 let size_bytes = png.len();
707 let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
708 let img = oxi_ai::ContentBlock::Image(oxi_ai::ImageContent::new(b64, "image/png"));
709
710 Ok(AgentToolResult::success(json_str(&json!({
711 "status": "ok",
712 "size_bytes": size_bytes,
713 })))
714 .with_content_blocks(vec![img]))
715 }
716
717 "scroll_into_view" => {
719 self.check_idle_timeout().await?;
720 let sel =
721 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
722 let slot = self.tab.lock().await;
723 let tab = require_tab(&slot)?;
724 tab.scroll_into_view(sel).await.map_err(browser_err)?;
725 Ok(json_ok())
726 }
727
728 "hover" => {
729 self.check_idle_timeout().await?;
730 let sel =
731 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
732 let slot = self.tab.lock().await;
733 let tab = require_tab(&slot)?;
734 tab.hover(sel).await.map_err(browser_err)?;
735 Ok(json_ok())
736 }
737
738 "double_click" => {
739 self.check_idle_timeout().await?;
740 let sel =
741 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
742 let slot = self.tab.lock().await;
743 let tab = require_tab(&slot)?;
744 tab.double_click(sel).await.map_err(browser_err)?;
745 Ok(json_ok())
746 }
747
748 "right_click" => {
749 self.check_idle_timeout().await?;
750 let sel =
751 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
752 let slot = self.tab.lock().await;
753 let tab = require_tab(&slot)?;
754 tab.right_click(sel).await.map_err(browser_err)?;
755 Ok(json_ok())
756 }
757
758 "drag" => {
759 self.check_idle_timeout().await?;
760 let from = from_selector
761 .ok_or_else(|| "Missing required parameter: from_selector".to_string())?;
762 let to = to_selector
763 .ok_or_else(|| "Missing required parameter: to_selector".to_string())?;
764 let slot = self.tab.lock().await;
765 let tab = require_tab(&slot)?;
766 tab.drag(from, to).await.map_err(browser_err)?;
767 Ok(json_ok())
768 }
769
770 "upload_file" => {
771 self.check_idle_timeout().await?;
772 let sel =
773 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
774 let path =
775 file_path.ok_or_else(|| "Missing required parameter: file_path".to_string())?;
776 let slot = self.tab.lock().await;
777 let tab = require_tab(&slot)?;
778 tab.upload_file(sel, path).await.map_err(browser_err)?;
779 Ok(json_ok())
780 }
781
782 "get_value" => {
783 self.check_idle_timeout().await?;
784 let sel =
785 selector.ok_or_else(|| "Missing required parameter: selector".to_string())?;
786 let slot = self.tab.lock().await;
787 let tab = require_tab(&slot)?;
788 let result_val = tab.get_value(sel).await.map_err(browser_err)?;
789 Ok(AgentToolResult::success(json_str(&json!({
790 "status": "ok",
791 "value": result_val,
792 }))))
793 }
794
795 _ => Err(format!(
796 "Unknown action: '{}'. Valid actions: open, goto, back, forward, reload, \
797 click, fill, type, clear, press, select, check, uncheck, scroll, \
798 scroll_into_view, hover, double_click, right_click, drag, upload_file, \
799 wait_for, wait, content, observe, query_all, extract_links, evaluate, \
800 evaluate_await, get_value, screenshot, close",
801 action
802 )),
803 }
804 }
805}
806
807fn require_tab(slot: &Option<TabGuard>) -> Result<&dyn super::engine::BrowserTab, ToolError> {
811 match slot {
812 Some(guard) => Ok(guard.tab()),
813 None => Err(BrowserError::NoActiveSession.into()),
814 }
815}
816
817fn json_str(v: &Value) -> String {
819 serde_json::to_string_pretty(v).unwrap_or_default()
820}
821
822fn json_ok() -> AgentToolResult {
824 AgentToolResult::success(json_str(&json!({ "status": "ok" })))
825}
826
827fn json_error(msg: &str) -> AgentToolResult {
829 AgentToolResult::success(json_str(&json!({
830 "status": "error",
831 "error": msg,
832 })))
833}
834
835fn browser_err(e: BrowserError) -> ToolError {
837 e.to_string()
838}
839
840#[cfg(test)]
843mod tests {
844 use super::*;
845 use crate::tools::browse::engine::{BrowserError, PageContent};
846 use async_trait::async_trait;
847 use std::sync::atomic::{AtomicBool, Ordering};
848
849 struct MockTab {
852 closed: Arc<AtomicBool>,
853 }
854
855 impl MockTab {
856 fn new() -> (Self, Arc<AtomicBool>) {
857 let closed = Arc::new(AtomicBool::new(false));
858 (
859 Self {
860 closed: closed.clone(),
861 },
862 closed,
863 )
864 }
865 }
866
867 #[async_trait]
868 impl super::super::engine::BrowserTab for MockTab {
869 async fn goto(&self, _url: &str) -> Result<PageContent, BrowserError> {
870 Ok(PageContent {
871 url: "https://example.com".into(),
872 title: "Example".into(),
873 status: 200,
874 markdown: "# Example\nHello".into(),
875 html: "<h1>Example</h1>".into(),
876 })
877 }
878 async fn click(&self, _selector: &str) -> Result<(), BrowserError> {
879 Ok(())
880 }
881 async fn type_(&self, _selector: &str, _text: &str) -> Result<(), BrowserError> {
882 Ok(())
883 }
884 async fn fill(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
885 Ok(())
886 }
887 async fn press(&self, _combo: &str) -> Result<(), BrowserError> {
888 Ok(())
889 }
890 async fn wait_for(&self, _selector: &str, _timeout_ms: u64) -> Result<(), BrowserError> {
891 Ok(())
892 }
893 async fn content(&self) -> Result<PageContent, BrowserError> {
894 Ok(PageContent {
895 url: "https://example.com".into(),
896 title: "Example".into(),
897 status: 200,
898 markdown: "# Example\nHello".into(),
899 html: "<h1>Example</h1>".into(),
900 })
901 }
902 async fn query_all(&self, _selector: &str) -> Result<Vec<String>, BrowserError> {
903 Ok(vec!["item1".into(), "item2".into()])
904 }
905 async fn evaluate(&self, _js: &str) -> Result<Value, BrowserError> {
906 Ok(Value::String("ok".into()))
907 }
908 async fn screenshot(&self, _width: u32) -> Result<Vec<u8>, BrowserError> {
909 Ok(vec![0x89, 0x50, 0x4E, 0x47]) }
911 async fn close(&self) -> Result<(), BrowserError> {
912 self.closed.store(true, Ordering::SeqCst);
913 Ok(())
914 }
915 async fn back(&self) -> Result<PageContent, BrowserError> {
916 Ok(PageContent::empty())
917 }
918 async fn forward(&self) -> Result<PageContent, BrowserError> {
919 Ok(PageContent::empty())
920 }
921 async fn reload(&self) -> Result<PageContent, BrowserError> {
922 Ok(PageContent::empty())
923 }
924 async fn select_option(&self, _selector: &str, _value: &str) -> Result<(), BrowserError> {
925 Ok(())
926 }
927 async fn check(&self, _selector: &str) -> Result<(), BrowserError> {
928 Ok(())
929 }
930 async fn uncheck(&self, _selector: &str) -> Result<(), BrowserError> {
931 Ok(())
932 }
933 async fn hover(&self, _selector: &str) -> Result<(), BrowserError> {
934 Ok(())
935 }
936 async fn double_click(&self, _selector: &str) -> Result<(), BrowserError> {
937 Ok(())
938 }
939 async fn right_click(&self, _selector: &str) -> Result<(), BrowserError> {
940 Ok(())
941 }
942 async fn scroll_into_view(&self, _selector: &str) -> Result<(), BrowserError> {
943 Ok(())
944 }
945 async fn drag(&self, _from_selector: &str, _to_selector: &str) -> Result<(), BrowserError> {
946 Ok(())
947 }
948 async fn upload_file(&self, _selector: &str, _path: &str) -> Result<(), BrowserError> {
949 Ok(())
950 }
951 async fn get_value(&self, _selector: &str) -> Result<String, BrowserError> {
952 Ok("mock_value".into())
953 }
954 async fn evaluate_await(&self, _js: &str) -> Result<Value, BrowserError> {
955 Ok(Value::String("ok".into()))
956 }
957 }
958
959 struct MockEngine;
962
963 #[async_trait]
964 impl super::super::engine::BrowserEngine for MockEngine {
965 async fn new_tab(&self) -> Result<Box<dyn super::super::engine::BrowserTab>, BrowserError> {
966 let (tab, _) = MockTab::new();
967 Ok(Box::new(tab) as Box<dyn super::super::engine::BrowserTab>)
968 }
969 async fn close(&self) -> Result<(), BrowserError> {
970 Ok(())
971 }
972 async fn is_alive(&self) -> bool {
973 true
974 }
975 }
976
977 fn make_tool() -> BrowseSessionTool {
979 let engine: Arc<dyn BrowserEngine> = Arc::new(MockEngine);
980 BrowseSessionTool::new(engine)
981 }
982
983 #[tokio::test]
986 async fn test_open_close_lifecycle() {
987 let tool = make_tool();
988 let ctx = ToolContext::default();
989
990 let result = tool
991 .execute("c1", json!({"action": "open"}), None, &ctx)
992 .await
993 .unwrap();
994 assert!(result.success);
995 assert!(result.output.contains("ok"));
996
997 let result = tool
998 .execute("c2", json!({"action": "close"}), None, &ctx)
999 .await
1000 .unwrap();
1001 assert!(result.success);
1002 }
1003
1004 #[tokio::test]
1005 async fn test_goto_requires_open_session() {
1006 let tool = make_tool();
1007 let ctx = ToolContext::default();
1008
1009 let result = tool
1010 .execute(
1011 "c1",
1012 json!({"action": "goto", "url": "https://example.com"}),
1013 None,
1014 &ctx,
1015 )
1016 .await;
1017 assert!(result.is_err());
1018 assert!(
1019 result
1020 .unwrap_err()
1021 .to_string()
1022 .contains("no active session")
1023 );
1024 }
1025
1026 #[tokio::test]
1027 async fn test_open_goto_close() {
1028 let tool = make_tool();
1029 let ctx = ToolContext::default();
1030
1031 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1032 .await
1033 .unwrap();
1034
1035 let result = tool
1036 .execute(
1037 "c2",
1038 json!({"action": "goto", "url": "https://example.com"}),
1039 None,
1040 &ctx,
1041 )
1042 .await
1043 .unwrap();
1044 assert!(result.success);
1045 assert!(result.output.contains("example.com"));
1046 assert!(result.output.contains("200"));
1047
1048 let result = tool
1049 .execute("c3", json!({"action": "close"}), None, &ctx)
1050 .await
1051 .unwrap();
1052 assert!(result.success);
1053 }
1054
1055 #[tokio::test]
1056 async fn test_content_action() {
1057 let tool = make_tool();
1058 let ctx = ToolContext::default();
1059
1060 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1061 .await
1062 .unwrap();
1063 tool.execute(
1064 "c2",
1065 json!({"action": "goto", "url": "https://example.com"}),
1066 None,
1067 &ctx,
1068 )
1069 .await
1070 .unwrap();
1071
1072 let result = tool
1073 .execute(
1074 "c3",
1075 json!({"action": "content", "format": "markdown"}),
1076 None,
1077 &ctx,
1078 )
1079 .await
1080 .unwrap();
1081 assert!(result.success);
1082 assert!(result.output.contains("Example"));
1083 assert!(result.output.contains("Hello"));
1084
1085 tool.execute("c4", json!({"action": "close"}), None, &ctx)
1086 .await
1087 .unwrap();
1088 }
1089
1090 #[tokio::test]
1091 async fn test_query_all_action() {
1092 let tool = make_tool();
1093 let ctx = ToolContext::default();
1094
1095 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1096 .await
1097 .unwrap();
1098
1099 let result = tool
1100 .execute(
1101 "c2",
1102 json!({"action": "query_all", "selector": ".item"}),
1103 None,
1104 &ctx,
1105 )
1106 .await
1107 .unwrap();
1108 assert!(result.success);
1109 assert!(result.output.contains("item1"));
1110 assert!(result.output.contains("item2"));
1111
1112 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1113 .await
1114 .unwrap();
1115 }
1116
1117 #[tokio::test]
1118 async fn test_evaluate_action() {
1119 let tool = make_tool();
1120 let ctx = ToolContext::default();
1121
1122 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1123 .await
1124 .unwrap();
1125
1126 let result = tool
1127 .execute(
1128 "c2",
1129 json!({"action": "evaluate", "javascript": "document.title"}),
1130 None,
1131 &ctx,
1132 )
1133 .await
1134 .unwrap();
1135 assert!(result.success);
1136 assert!(result.output.contains("ok"));
1137
1138 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1139 .await
1140 .unwrap();
1141 }
1142
1143 #[tokio::test]
1144 async fn test_screenshot_action() {
1145 let tool = make_tool();
1146 let ctx = ToolContext::default();
1147
1148 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1149 .await
1150 .unwrap();
1151
1152 let result = tool
1153 .execute("c2", json!({"action": "screenshot"}), None, &ctx)
1154 .await
1155 .unwrap();
1156 assert!(result.success);
1157 assert!(result.output.contains("size_bytes"));
1158 assert!(result.content_blocks.is_some());
1159
1160 tool.execute("c3", json!({"action": "close"}), None, &ctx)
1161 .await
1162 .unwrap();
1163 }
1164
1165 #[tokio::test]
1166 async fn test_dom_actions() {
1167 let tool = make_tool();
1168 let ctx = ToolContext::default();
1169
1170 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1171 .await
1172 .unwrap();
1173
1174 let actions: Vec<(&str, Value)> = vec![
1175 ("click", json!({"action": "click", "selector": "#btn"})),
1176 (
1177 "fill",
1178 json!({"action": "fill", "selector": "#input", "value": "hello"}),
1179 ),
1180 (
1181 "type",
1182 json!({"action": "type", "selector": "#input", "value": "world"}),
1183 ),
1184 ("clear", json!({"action": "clear", "selector": "#input"})),
1185 ("press", json!({"action": "press", "combo": "Enter"})),
1186 ("check", json!({"action": "check", "selector": "#agree"})),
1187 (
1188 "uncheck",
1189 json!({"action": "uncheck", "selector": "#newsletter"}),
1190 ),
1191 ("scroll", json!({"action": "scroll", "pixels": 500})),
1192 (
1193 "wait_for",
1194 json!({"action": "wait_for", "selector": ".loaded"}),
1195 ),
1196 (
1197 "scroll_into_view",
1198 json!({"action": "scroll_into_view", "selector": "#section"}),
1199 ),
1200 ("hover", json!({"action": "hover", "selector": "#menu"})),
1201 (
1202 "double_click",
1203 json!({"action": "double_click", "selector": "#item"}),
1204 ),
1205 (
1206 "right_click",
1207 json!({"action": "right_click", "selector": "#item"}),
1208 ),
1209 (
1210 "get_value",
1211 json!({"action": "get_value", "selector": "#input"}),
1212 ),
1213 ];
1214
1215 for (name, params) in &actions {
1216 let result = tool.execute("cx", params.clone(), None, &ctx).await;
1217 assert!(result.is_ok(), "Action '{}' failed: {:?}", name, result);
1218 }
1219
1220 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1221 .await
1222 .unwrap();
1223 }
1224
1225 #[tokio::test]
1226 async fn test_navigation_actions() {
1227 let tool = make_tool();
1228 let ctx = ToolContext::default();
1229
1230 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1231 .await
1232 .unwrap();
1233
1234 for nav_action in &["back", "forward", "reload"] {
1235 let result = tool
1236 .execute("cx", json!({"action": *nav_action}), None, &ctx)
1237 .await;
1238 assert!(result.is_ok(), "Navigation action '{}' failed", nav_action);
1239 }
1240
1241 tool.execute("c99", json!({"action": "close"}), None, &ctx)
1242 .await
1243 .unwrap();
1244 }
1245
1246 #[tokio::test]
1247 async fn test_unknown_action() {
1248 let tool = make_tool();
1249 let ctx = ToolContext::default();
1250
1251 let result = tool
1252 .execute("c1", json!({"action": "nonexistent"}), None, &ctx)
1253 .await;
1254 assert!(result.is_err());
1255 assert!(result.unwrap_err().to_string().contains("Unknown action"));
1256 }
1257
1258 #[tokio::test]
1259 async fn test_close_without_open() {
1260 let tool = make_tool();
1261 let ctx = ToolContext::default();
1262
1263 let result = tool
1264 .execute("c1", json!({"action": "close"}), None, &ctx)
1265 .await
1266 .unwrap();
1267 assert!(result.success);
1268 assert!(result.output.contains("error"));
1269 }
1270
1271 #[tokio::test]
1272 async fn test_re_open_closes_previous() {
1273 let tool = make_tool();
1274 let ctx = ToolContext::default();
1275
1276 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1277 .await
1278 .unwrap();
1279
1280 let result = tool
1281 .execute("c2", json!({"action": "open"}), None, &ctx)
1282 .await
1283 .unwrap();
1284 assert!(result.success);
1285
1286 let result = tool
1287 .execute(
1288 "c3",
1289 json!({"action": "goto", "url": "https://example.com"}),
1290 None,
1291 &ctx,
1292 )
1293 .await
1294 .unwrap();
1295 assert!(result.success);
1296 }
1297
1298 #[tokio::test]
1299 async fn test_missing_required_params() {
1300 let tool = make_tool();
1301 let ctx = ToolContext::default();
1302
1303 tool.execute("c1", json!({"action": "open"}), None, &ctx)
1304 .await
1305 .unwrap();
1306
1307 assert!(
1309 tool.execute("c2", json!({"action": "goto"}), None, &ctx)
1310 .await
1311 .is_err()
1312 );
1313
1314 assert!(
1316 tool.execute("c3", json!({"action": "click"}), None, &ctx)
1317 .await
1318 .is_err()
1319 );
1320
1321 assert!(
1323 tool.execute(
1324 "c4",
1325 json!({"action": "fill", "selector": "#x"}),
1326 None,
1327 &ctx
1328 )
1329 .await
1330 .is_err()
1331 );
1332
1333 assert!(
1335 tool.execute("c5", json!({"action": "press"}), None, &ctx)
1336 .await
1337 .is_err()
1338 );
1339
1340 assert!(
1342 tool.execute("c6", json!({"action": "evaluate"}), None, &ctx)
1343 .await
1344 .is_err()
1345 );
1346
1347 tool.execute("c7", json!({"action": "close"}), None, &ctx)
1348 .await
1349 .unwrap();
1350 }
1351
1352 #[tokio::test]
1353 async fn test_name_label_description() {
1354 let tool = make_tool();
1355 assert_eq!(tool.name(), "browse_session");
1356 assert_eq!(tool.label(), "Browser Session");
1357 assert!(!tool.description().is_empty());
1358 }
1359
1360 #[tokio::test]
1361 async fn test_schema_has_all_actions() {
1362 let tool = make_tool();
1363 let schema = tool.parameters_schema();
1364 let actions = schema["properties"]["action"]["enum"].as_array().unwrap();
1365 assert_eq!(actions.len(), 31);
1366 }
1367}