1#![allow(clippy::useless_conversion, missing_docs)]
7
8use super::config::BrowseConfig;
9use super::engine::BrowserEngine;
10use super::helpers;
11use super::tab_guard::TabGuard;
12use crate::tools::{AgentTool, AgentToolResult, ToolContext, ToolError};
13use async_trait::async_trait;
14use parking_lot::Mutex;
15use serde::Deserialize;
16use serde_json::{Value, json};
17use std::path::Path;
18use std::sync::Arc;
19use tokio::sync::oneshot;
20
21#[allow(missing_docs)] #[derive(Debug, Clone, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum Step {
28 Goto { url: String },
30 Back,
32 Forward,
34 Reload,
36 Click { selector: String },
38 Fill { selector: String, value: String },
40 Type { selector: String, value: String },
42 Clear { selector: String },
44 Check { selector: String },
46 Uncheck { selector: String },
48 Select { selector: String, value: String },
50 Press { combo: String },
52 Scroll { pixels: u32 },
54 Wait { selector: String },
56 Evaluate { expr: String },
58 Extract {
60 selector: String,
61 #[serde(default)]
62 all: bool,
63 },
64 Content,
66 Screenshot,
68 Set { key: String, value: String },
70 Echo { message: String },
72 Sleep { ms: u64 },
74}
75
76pub struct ScriptResult {
78 pub outputs: Vec<String>,
79 pub screenshot: Option<Vec<u8>>,
80 pub variables: std::collections::HashMap<String, String>,
81}
82
83fn parse_steps(yaml: &str) -> Result<Vec<Step>, ToolError> {
86 let doc: serde_yaml::Value =
90 serde_yaml::from_str(yaml).map_err(|e| format!("Invalid YAML: {}", e))?;
91
92 let raw_seq = match &doc {
93 serde_yaml::Value::Sequence(_) => doc.clone(),
94 serde_yaml::Value::Mapping(map) => {
95 let mut found: Option<&serde_yaml::Value> = None;
98 for (k, v) in map.iter() {
99 if let serde_yaml::Value::String(s) = k
100 && s == "steps"
101 {
102 found = Some(v);
103 break;
104 }
105 }
106 match found {
107 Some(v) => v.clone(),
108 None => {
109 return Err("Missing 'steps' key in YAML document".into());
110 }
111 }
112 }
113 _ => return Err("YAML document must be a sequence or a map with a 'steps' key".into()),
114 };
115
116 let yaml_seq = raw_seq
126 .as_sequence()
127 .ok_or_else(|| "steps must be a YAML sequence".to_string())?;
128
129 let mut steps = Vec::with_capacity(yaml_seq.len());
130 for (i, item) in yaml_seq.iter().enumerate() {
131 let mapping = item
132 .as_mapping()
133 .ok_or_else(|| format!("step {} is not a YAML mapping", i))?;
134 if mapping.len() != 1 {
135 return Err(format!(
136 "step {} must have exactly one key (the variant name), got {}",
137 i,
138 mapping.len()
139 ));
140 }
141 #[allow(clippy::expect_used)]
144 let (variant_key, payload) = mapping
145 .iter()
146 .next()
147 .expect("mapping.len() == 1 checked above");
148 let variant = variant_key
149 .as_str()
150 .ok_or_else(|| format!("step {} variant name is not a string", i))?;
151 let step = step_from_yaml_payload(variant, payload)
152 .map_err(|e| format!("step {} ({}): {}", i, variant, e))?;
153 steps.push(step);
154 }
155
156 Ok(steps)
157}
158
159fn step_from_yaml_payload(variant: &str, payload: &serde_yaml::Value) -> Result<Step, String> {
166 use serde_yaml::Value as Y;
167
168 let wrapper = match payload {
173 Y::Null => serde_json::json!({ variant: {} }),
174 Y::Mapping(m) if m.is_empty() => {
175 serde_json::json!({ variant: null })
177 }
178 Y::String(s) => {
179 let single_field = single_field_struct_variant(variant)
182 .ok_or_else(|| format!("variant '{}' has no single-field shorthand", variant))?;
183 serde_json::json!({ variant: { single_field: s } })
184 }
185 _ => {
186 let payload_json = yaml_to_json_recursive(payload)?;
187 serde_json::json!({ variant: payload_json })
188 }
189 };
190 serde_json::from_value(wrapper).map_err(|e| e.to_string())
191}
192
193fn single_field_struct_variant(variant: &str) -> Option<&'static str> {
196 match variant {
197 "goto" => Some("url"),
198 "click" => Some("selector"),
199 "fill" | "fill_" => Some("selector"), "type" | "type_" => Some("selector"),
201 "clear" => Some("selector"),
202 "check" | "uncheck" => Some("selector"),
203 "select" => Some("selector"),
204 "press" => Some("combo"),
205 "wait" => Some("selector"),
206 "evaluate" => Some("expr"),
207 "extract" => Some("selector"),
208 "set" => Some("key"),
209 "echo" => Some("message"),
210 _ => None,
211 }
212}
213
214fn yaml_to_json_recursive(v: &serde_yaml::Value) -> Result<serde_json::Value, ToolError> {
215 use serde_yaml::Value as Y;
216 match v {
217 Y::Null => Ok(serde_json::Value::Null),
218 Y::Bool(b) => Ok(serde_json::Value::Bool(*b)),
219 Y::Number(n) => {
220 if let Some(i) = n.as_i64() {
221 Ok(serde_json::Value::Number(i.into()))
222 } else if let Some(u) = n.as_u64() {
223 Ok(serde_json::Value::Number(u.into()))
224 } else if let Some(f) = n.as_f64() {
225 serde_json::Number::from_f64(f)
226 .map(serde_json::Value::Number)
227 .ok_or_else(|| "non-finite number".to_string())
228 } else {
229 Err("unsupported number type".to_string())
230 }
231 }
232 Y::String(s) => Ok(serde_json::Value::String(s.clone())),
233 Y::Sequence(items) => {
234 let arr: Result<Vec<serde_json::Value>, ToolError> =
235 items.iter().map(yaml_to_json_recursive).collect();
236 Ok(serde_json::Value::Array(arr?))
237 }
238 Y::Mapping(map) => {
239 let mut obj = serde_json::Map::new();
240 for (k, v) in map.iter() {
241 let key = k
242 .as_str()
243 .ok_or_else(|| "non-string mapping key".to_string())?
244 .to_string();
245 obj.insert(key, yaml_to_json_recursive(v)?);
246 }
247 Ok(serde_json::Value::Object(obj))
248 }
249 Y::Tagged(t) => yaml_to_json_recursive(&t.value),
250 }
251}
252
253async fn execute_steps(
256 tab: &dyn super::engine::BrowserTab,
257 steps: &[Step],
258 config: &BrowseConfig,
259 deadline: tokio::time::Instant,
260 progress_cb: Option<&crate::tools::ProgressCallback>,
261) -> Result<ScriptResult, ToolError> {
262 let mut result = ScriptResult {
263 outputs: Vec::new(),
264 screenshot: None,
265 variables: std::collections::HashMap::new(),
266 };
267
268 for (i, step) in steps.iter().enumerate() {
269 if tokio::time::Instant::now() > deadline {
270 return Err(format!("Script timed out at step {} of {}", i + 1, steps.len()).into());
271 }
272
273 if i >= config.max_script_steps {
274 return Err(format!(
275 "Exceeded maximum script steps ({})",
276 config.max_script_steps
277 )
278 .into());
279 }
280
281 if let Some(cb) = progress_cb {
283 cb(format!("[{}/{}] {}", i + 1, steps.len(), step_label(step)));
284 }
285
286 execute_single_step(tab, step, &mut result, config).await?;
287 }
288
289 Ok(result)
290}
291
292fn step_label(step: &Step) -> &'static str {
294 match step {
295 Step::Goto { .. } => "Navigating",
296 Step::Click { .. } => "Clicking element",
297 Step::Fill { .. } => "Filling input",
298 Step::Type { .. } => "Typing text",
299 Step::Clear { .. } => "Clearing input",
300 Step::Check { .. } => "Checking checkbox",
301 Step::Uncheck { .. } => "Unchecking checkbox",
302 Step::Select { .. } => "Selecting option",
303 Step::Press { .. } => "Pressing key",
304 Step::Scroll { .. } => "Scrolling",
305 Step::Wait { .. } => "Waiting for element",
306 Step::Evaluate { .. } => "Evaluating JavaScript",
307 Step::Extract { .. } => "Extracting data",
308 Step::Content => "Reading page content",
309 Step::Screenshot => "Taking screenshot",
310 Step::Set { .. } => "Setting variable",
311 Step::Echo { .. } => "Echo",
312 Step::Sleep { .. } => "Sleeping",
313 Step::Back => "Going back",
314 Step::Forward => "Going forward",
315 Step::Reload => "Reloading page",
316 }
317}
318
319async fn execute_single_step(
320 tab: &dyn super::engine::BrowserTab,
321 step: &Step,
322 result: &mut ScriptResult,
323 config: &BrowseConfig,
324) -> Result<(), ToolError> {
325 match step {
326 Step::Goto { url } => {
327 tab.goto(url).await.map_err(|e| e.to_string())?;
328 }
329 Step::Click { selector } => {
330 tab.click(selector).await.map_err(|e| e.to_string())?;
331 }
332 Step::Fill { selector, value } => {
333 tab.fill(selector, value).await.map_err(|e| e.to_string())?;
334 }
335 Step::Type { selector, value } => {
336 tab.type_(selector, value)
337 .await
338 .map_err(|e| e.to_string())?;
339 }
340 Step::Clear { selector } => {
341 tab.fill(selector, "").await.map_err(|e| e.to_string())?;
343 }
344 Step::Check { selector } => {
345 let js = helpers::js_check(selector);
346 tab.evaluate(&js).await.map_err(|e| e.to_string())?;
347 }
348 Step::Uncheck { selector } => {
349 let js = helpers::js_uncheck(selector);
350 tab.evaluate(&js).await.map_err(|e| e.to_string())?;
351 }
352 Step::Select { selector, value } => {
353 if value.is_empty() {
354 return Err("Select step requires a non-empty value".into());
355 }
356 let js = helpers::js_set_select_value(selector, value);
357 tab.evaluate(&js).await.map_err(|e| e.to_string())?;
358 }
359 Step::Press { combo } => {
360 tab.press(combo).await.map_err(|e| e.to_string())?;
361 }
362 Step::Scroll { pixels } => {
363 let js = format!("window.scrollBy(0, {})", pixels);
364 tab.evaluate(&js).await.map_err(|e| e.to_string())?;
365 }
366 Step::Wait { selector } => {
367 tab.wait_for(selector, config.default_wait_timeout_ms)
368 .await
369 .map_err(|e| e.to_string())?;
370 }
371 Step::Evaluate { expr } => {
372 let value = tab.evaluate(expr).await.map_err(|e| e.to_string())?;
373 let text = match value {
374 Value::String(s) => s,
375 other => serde_json::to_string(&other).unwrap_or_default(),
376 };
377 result.outputs.push(text);
378 }
379 Step::Extract { selector, all } => {
380 let texts = tab.query_all(selector).await.map_err(|e| e.to_string())?;
381 let texts = if *all {
382 texts
383 } else {
384 texts.into_iter().take(1).collect()
385 };
386 result.outputs.push(texts.join("\n"));
387 }
388 Step::Content => {
389 let page = tab.content().await.map_err(|e| e.to_string())?;
390 result.outputs.push(page.markdown);
391 }
392 Step::Screenshot => {
393 let png = tab
394 .screenshot(config.screenshot_width)
395 .await
396 .map_err(|e| e.to_string())?;
397 result.screenshot = Some(png);
398 }
399 Step::Set { key, value } => {
400 result.variables.insert(key.clone(), value.clone());
401 }
402 Step::Echo { message } => {
403 result.outputs.push(message.clone());
404 }
405 Step::Sleep { ms } => {
406 tokio::time::sleep(std::time::Duration::from_millis(*ms)).await;
407 }
408 Step::Back => {
410 let _ = tab.evaluate("history.back()").await;
411 }
412 Step::Forward => {
413 let _ = tab.evaluate("history.forward()").await;
414 }
415 Step::Reload => {
416 let _ = tab.evaluate("location.reload()").await;
417 }
418 }
419 Ok(())
420}
421
422pub struct BrowseScriptTool {
426 engine: Arc<dyn BrowserEngine>,
427 config: BrowseConfig,
428 callbacks: super::callback_mixin::BrowseCallbacks,
430 tab_id_slot: Mutex<Arc<parking_lot::Mutex<Option<uuid::Uuid>>>>,
432}
433
434impl BrowseScriptTool {
435 pub fn new(engine: Arc<dyn BrowserEngine>) -> Self {
437 Self {
438 engine,
439 config: BrowseConfig::default(),
440 callbacks: super::callback_mixin::BrowseCallbacks::new(),
441 tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
442 }
443 }
444
445 pub fn with_config(engine: Arc<dyn BrowserEngine>, config: BrowseConfig) -> Self {
447 Self {
448 engine,
449 config,
450 callbacks: super::callback_mixin::BrowseCallbacks::new(),
451 tab_id_slot: Mutex::new(Arc::new(parking_lot::Mutex::new(None))),
452 }
453 }
454}
455
456#[async_trait]
457impl AgentTool for BrowseScriptTool {
458 fn name(&self) -> &str {
459 "browse_script"
460 }
461
462 fn label(&self) -> &str {
463 "Browser Script"
464 }
465
466 fn description(&self) -> &str {
467 "Run a multi-step browser automation script in YAML format. \
468 Supports: goto, click, fill, type, press, wait, extract, evaluate, \
469 check, uncheck, select, scroll, screenshot, content, sleep."
470 }
471
472 fn on_progress(&self, callback: crate::tools::ProgressCallback) {
473 self.callbacks.store_progress(callback);
474 }
475
476 fn on_browse_progress(&self, callback: Arc<dyn Fn(super::BrowseProgress) + Send + Sync>) {
477 self.callbacks.store_browse(callback);
478 }
479
480 fn set_tab_id_slot(&self, slot: Arc<parking_lot::Mutex<Option<uuid::Uuid>>>) {
481 *self.tab_id_slot.lock() = slot;
482 }
483
484 fn current_tab_id(&self) -> Option<uuid::Uuid> {
485 *self.tab_id_slot.lock().lock()
486 }
487
488 fn parameters_schema(&self) -> Value {
489 json!({
490 "type": "object",
491 "properties": {
492 "script": {
493 "type": "string",
494 "description": "YAML script (inline or path to .yaml file)"
495 },
496 "timeout": {
497 "type": "integer",
498 "default": 60,
499 "description": "Maximum execution time in seconds"
500 }
501 },
502 "required": ["script"]
503 })
504 }
505
506 async fn execute(
507 &self,
508 _tool_call_id: &str,
509 params: Value,
510 _signal: Option<oneshot::Receiver<()>>,
511 _ctx: &ToolContext,
512 ) -> Result<AgentToolResult, ToolError> {
513 let script_input = params["script"]
514 .as_str()
515 .ok_or_else(|| "Missing required parameter: script".to_string())?;
516
517 let timeout_secs = params["timeout"].as_u64().unwrap_or(60);
518 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
519
520 let yaml = if Path::new(script_input).exists() {
522 std::fs::read_to_string(script_input)
523 .map_err(|e| format!("Failed to read script file: {}", e))?
524 } else {
525 script_input.to_string()
526 };
527
528 let steps = parse_steps(&yaml)?;
529 if steps.is_empty() {
530 return Err("Script contains no steps".into());
531 }
532
533 tracing::info!(steps = steps.len(), "executing browse script");
534
535 let progress_cb = self.callbacks.take_progress();
538
539 let raw_tab = self
541 .engine
542 .new_tab()
543 .await
544 .map_err(|e| format!("Failed to open browser tab: {}", e))?;
545
546 let tab_id = raw_tab.tab_id();
547 *self.tab_id_slot.lock().lock() = Some(tab_id);
548
549 if let Some(ref cb) = progress_cb {
551 let registry = self.engine.callback_registry();
552 registry.set(tab_id, cb.clone());
553 }
554 self.callbacks
556 .register_browse_on_registry(tab_id, self.engine.callback_registry().as_ref());
557
558 let guard = TabGuard::new(raw_tab);
559
560 let script_result = execute_steps(
561 guard.tab(),
562 &steps,
563 &self.config,
564 deadline,
565 progress_cb.as_ref(),
566 )
567 .await?;
568
569 let mut output_parts = Vec::new();
571 if !script_result.outputs.is_empty() {
572 output_parts.push(script_result.outputs.join("\n"));
573 }
574
575 let metadata = json!({
576 "steps_executed": steps.len(),
577 "variables": script_result.variables,
578 });
579
580 let mut result = AgentToolResult::success(output_parts.join("\n")).with_metadata(metadata);
581
582 if let Some(png) = script_result.screenshot {
584 let b64 = base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &png);
585 let img =
586 oxicode_ai::ContentBlock::Image(oxicode_ai::ImageContent::new(b64, "image/png"));
587 result = result.with_content_blocks(vec![img]);
588 }
589
590 guard.close().await;
591 *self.tab_id_slot.lock().lock() = None;
592 Ok(result)
593 }
594}
595
596#[cfg(test)]
599mod tests {
600 use super::*;
601
602 #[test]
603 fn parse_simple_goto() {
604 let yaml = r##"
605steps:
606 - goto: "https://example.com"
607 - click: "button.submit"
608 - fill:
609 selector: "#search"
610 value: "rust"
611"##;
612 let steps = parse_steps(yaml).unwrap();
613 assert_eq!(steps.len(), 3);
614 assert!(matches!(&steps[0], Step::Goto { url } if url == "https://example.com"));
615 assert!(matches!(&steps[1], Step::Click { selector } if selector == "button.submit"));
616 assert!(
617 matches!(&steps[2], Step::Fill { selector, value } if selector == "#search" && value == "rust")
618 );
619 }
620
621 #[test]
622 fn parse_extract_step() {
623 let yaml = r#"
624steps:
625 - extract:
626 selector: ".result h3"
627 all: true
628"#;
629 let steps = parse_steps(yaml).unwrap();
630 assert_eq!(steps.len(), 1);
631 assert!(
632 matches!(&steps[0], Step::Extract { selector, all } if selector == ".result h3" && *all)
633 );
634 }
635
636 #[test]
637 fn parse_evaluate_step() {
638 let yaml = r#"
639steps:
640 - evaluate:
641 expr: "document.title"
642"#;
643 let steps = parse_steps(yaml).unwrap();
644 assert!(matches!(&steps[0], Step::Evaluate { expr } if expr == "document.title"));
645 }
646
647 #[test]
648 fn parse_screenshot_step() {
649 let yaml = r#"
650steps:
651 - goto: "https://example.com"
652 - screenshot: {}
653"#;
654 let steps = parse_steps(yaml).unwrap();
655 assert!(matches!(&steps[1], Step::Screenshot));
656 }
657
658 #[test]
659 fn parse_wait_step() {
660 let yaml = r#"
661steps:
662 - wait:
663 selector: ".loaded"
664"#;
665 let steps = parse_steps(yaml).unwrap();
666 assert!(matches!(&steps[0], Step::Wait { selector } if selector == ".loaded"));
667 }
668
669 #[test]
670 fn parse_press_step() {
671 let yaml = r#"
672steps:
673 - press:
674 combo: "Enter"
675"#;
676 let steps = parse_steps(yaml).unwrap();
677 assert!(matches!(&steps[0], Step::Press { combo } if combo == "Enter"));
678 }
679
680 #[test]
681 fn parse_scroll_step() {
682 let yaml = r#"
683steps:
684 - scroll:
685 pixels: 500
686"#;
687 let steps = parse_steps(yaml).unwrap();
688 assert!(matches!(&steps[0], Step::Scroll { pixels } if *pixels == 500));
689 }
690
691 #[test]
692 fn parse_select_step() {
693 let yaml = r##"
694steps:
695 - select:
696 selector: "#country"
697 value: "US"
698"##;
699 let steps = parse_steps(yaml).unwrap();
700 assert!(
701 matches!(&steps[0], Step::Select { selector, value } if selector == "#country" && value == "US")
702 );
703 }
704
705 #[test]
706 fn parse_check_uncheck_steps() {
707 let yaml = r##"
708steps:
709 - check:
710 selector: "#agree"
711 - uncheck:
712 selector: "#newsletter"
713"##;
714 let steps = parse_steps(yaml).unwrap();
715 assert!(matches!(&steps[0], Step::Check { selector } if selector == "#agree"));
716 assert!(matches!(&steps[1], Step::Uncheck { selector } if selector == "#newsletter"));
717 }
718
719 #[test]
720 fn parse_empty_script_returns_error() {
721 let yaml = r#"
722steps: []
723"#;
724 let steps = parse_steps(yaml).unwrap();
725 assert!(steps.is_empty());
726 }
727
728 #[test]
729 fn test_js_helpers() {
730 let sel_js = helpers::js_set_select_value("#country", "US");
732 assert!(sel_js.contains("#country"));
733 assert!(sel_js.contains("US"));
734
735 let check_js = helpers::js_check("#agree");
736 assert!(check_js.contains("#agree"));
737 assert!(check_js.contains("!el.checked"));
738
739 let uncheck_js = helpers::js_uncheck("#newsletter");
740 assert!(uncheck_js.contains("#newsletter"));
741 assert!(uncheck_js.contains("el.checked"));
742 }
743}