1use crate::ai::llm_provider::{LLMConfig, LLMContent, LLMMessage, LLMProvider, LLMRole};
8use crate::ai::prompts::{build_system_prompt, build_user_message};
9use crate::errors::{Result, SpiderError};
10use crate::events::SpiderEventEmitter;
11use crate::protocol::protocol_adapter::ProtocolAdapter;
12use serde::{Deserialize, Serialize};
13use serde_json::{json, Value};
14use tokio::time::{sleep, Duration};
15use tracing::{info, warn};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
25pub enum AgentScope {
26 #[default]
27 Browser,
28 Page,
29}
30
31pub const GUARDRAIL_JS: &str = r#"(function () {
47 if (window.__spiderPageScopeGuard) return;
48 window.__spiderPageScopeGuard = true;
49 try {
50 window.open = function (url) {
51 if (url) { try { window.location.href = String(url); } catch (e) {} }
52 return null;
53 };
54 } catch (e) {}
55 var retarget = function () {
56 try {
57 var links = document.querySelectorAll('a[target="_blank"]');
58 for (var i = 0; i < links.length; i++) { links[i].target = '_self'; }
59 } catch (e) {}
60 };
61 retarget();
62 try {
63 new MutationObserver(retarget).observe(document, { childList: true, subtree: true });
64 } catch (e) {}
65})();"#;
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
76pub enum AgentAction {
77 Click(String),
78 ClickAll(String),
79 ClickPoint { x: f64, y: f64 },
80 ClickHold { selector: String, hold_ms: u64 },
81 ClickHoldPoint { x: f64, y: f64, hold_ms: u64 },
82 DoubleClick(String),
83 DoubleClickPoint { x: f64, y: f64 },
84 RightClick(String),
85 RightClickPoint { x: f64, y: f64 },
86 WaitForAndClick(String),
87 ClickDrag {
88 from: String,
89 to: String,
90 #[serde(default)]
91 modifier: Option<u32>,
92 },
93 ClickDragPoint {
94 from_x: f64,
95 from_y: f64,
96 to_x: f64,
97 to_y: f64,
98 #[serde(default)]
99 modifier: Option<u32>,
100 },
101 Type { value: String },
102 Fill { selector: String, value: String },
103 Clear(String),
104 Press(String),
105 KeyDown(String),
106 KeyUp(String),
107 Select { selector: String, value: String },
108 Focus(String),
109 Blur(String),
110 Hover(String),
111 HoverPoint { x: f64, y: f64 },
112 ScrollY(f64),
113 ScrollX(f64),
114 ScrollTo { selector: String },
115 ScrollToPoint { x: f64, y: f64 },
116 InfiniteScroll(u32),
117 Wait(u64),
118 WaitFor(String),
119 WaitForWithTimeout { selector: String, timeout: u64 },
120 WaitForNavigation,
121 WaitForDom {
122 #[serde(default)]
123 selector: Option<String>,
124 timeout: u64,
125 },
126 Navigate(String),
127 GoBack,
128 GoForward,
129 Reload,
130 SetViewport {
131 width: u32,
132 height: u32,
133 #[serde(default)]
134 device_scale_factor: Option<f64>,
135 #[serde(default)]
136 mobile: Option<bool>,
137 },
138 Evaluate(String),
139 Screenshot,
140}
141
142#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct AgentPlan {
145 #[serde(default)]
146 pub label: String,
147 #[serde(default)]
148 pub done: bool,
149 #[serde(default)]
150 pub steps: Option<Vec<AgentAction>>,
151 #[serde(default)]
152 pub extracted: Option<Value>,
153 #[serde(default)]
154 pub memory_ops: Option<Vec<Value>>,
155}
156
157#[derive(Debug, Clone)]
159pub struct AgentOptions {
160 pub max_rounds: u32,
162 pub step_delay_ms: u64,
164 pub instruction: Option<String>,
166 pub scope: AgentScope,
168 pub llm: Option<LLMConfig>,
172}
173
174impl Default for AgentOptions {
175 fn default() -> Self {
176 Self {
177 max_rounds: 30,
178 step_delay_ms: 1500,
179 instruction: None,
180 scope: AgentScope::default(),
181 llm: None,
182 }
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct AgentResult {
189 pub done: bool,
191 pub rounds: u32,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub extracted: Option<Value>,
196 #[serde(default)]
198 pub label: String,
199}
200
201pub struct Agent<'a> {
203 adapter: &'a ProtocolAdapter,
204 llm: &'a dyn LLMProvider,
205 emitter: &'a SpiderEventEmitter,
206 max_rounds: u32,
207 step_delay_ms: u64,
208 scope: AgentScope,
209}
210
211impl<'a> Agent<'a> {
212 pub fn new(
213 adapter: &'a ProtocolAdapter,
214 llm: &'a dyn LLMProvider,
215 emitter: &'a SpiderEventEmitter,
216 options: Option<AgentOptions>,
217 ) -> Self {
218 let opts = options.unwrap_or_default();
219 Self {
220 adapter,
221 llm,
222 emitter,
223 max_rounds: opts.max_rounds,
224 step_delay_ms: opts.step_delay_ms,
225 scope: opts.scope,
226 }
227 }
228
229 pub async fn execute(&self, instruction: &str) -> AgentResult {
231 let mut extracted: Option<Value> = None;
232 let mut last_label = String::new();
233
234 sleep(Duration::from_millis(500)).await;
236
237 let system_prompt = build_system_prompt(self.scope);
238
239 let mut guardrail_persistent = false;
247 if self.scope == AgentScope::Page {
248 let _ = self.adapter.evaluate(GUARDRAIL_JS).await;
249 guardrail_persistent = self
250 .adapter
251 .send_command(
252 "Page.addScriptToEvaluateOnNewDocument",
253 json!({ "source": GUARDRAIL_JS }),
254 )
255 .await
256 .is_ok();
257 }
258
259 for round in 0..self.max_rounds {
260 if self.scope == AgentScope::Page && !guardrail_persistent {
263 let _ = self.adapter.evaluate(GUARDRAIL_JS).await;
264 }
265 let screenshot = match self.adapter.capture_screenshot().await {
267 Ok(s) => s,
268 Err(err) => {
269 warn!(round, error = %err, "agent: screenshot failed");
270 break;
271 }
272 };
273
274 let html = match self.adapter.get_html().await {
276 Ok(h) => h,
277 Err(err) => {
278 warn!(round, error = %err, "agent: get HTML failed");
279 break;
280 }
281 };
282
283 let url = self
285 .adapter
286 .evaluate("window.location.href")
287 .await
288 .ok()
289 .and_then(|v| v.as_str().map(String::from))
290 .unwrap_or_else(|| "unknown".to_string());
291
292 let title = self
293 .adapter
294 .evaluate("document.title")
295 .await
296 .ok()
297 .and_then(|v| v.as_str().map(String::from))
298 .unwrap_or_default();
299
300 let context = format!(
302 "Round {}/{}. Task: {instruction}\nPAGE TITLE: {title}",
303 round + 1,
304 self.max_rounds
305 );
306
307 let messages = vec![
308 LLMMessage::system(system_prompt.as_ref()),
309 LLMMessage {
310 role: LLMRole::User,
311 content: LLMContent::Parts(build_user_message(
312 &url,
313 &html,
314 &screenshot,
315 Some(&context),
316 )),
317 },
318 ];
319
320 let plan: AgentPlan = match crate::ai::llm_provider::chat_json(self.llm, &messages).await {
321 Ok(p) => p,
322 Err(err) => {
323 warn!(round, error = %err, "agent: LLM call failed");
324 sleep(Duration::from_millis(2000)).await;
325 continue;
326 }
327 };
328
329 last_label = plan.label.clone();
330 if plan.extracted.is_some() {
331 extracted = plan.extracted.clone();
332 }
333
334 let steps_count = plan.steps.as_ref().map(|s| s.len()).unwrap_or(0);
335
336 info!(
337 round = round + 1,
338 label = %plan.label,
339 done = plan.done,
340 steps = steps_count,
341 "agent: round"
342 );
343
344 self.emitter.emit(
345 "agent.step",
346 json!({
347 "round": round + 1,
348 "label": plan.label,
349 "stepsCount": steps_count,
350 }),
351 );
352
353 if plan.done {
355 self.emitter.emit(
356 "agent.done",
357 json!({
358 "rounds": round + 1,
359 "result": extracted,
360 }),
361 );
362 return AgentResult {
363 done: true,
364 rounds: round + 1,
365 extracted,
366 label: last_label,
367 };
368 }
369
370 if steps_count == 0 {
371 info!("agent: no steps, retrying");
372 sleep(Duration::from_millis(self.step_delay_ms)).await;
373 continue;
374 }
375
376 if let Some(ref steps) = plan.steps {
378 for (i, action) in steps.iter().enumerate() {
379 if let Err(err) = execute_action(self.adapter, action).await {
380 warn!(
381 round,
382 step = i,
383 error = %err,
384 "agent: action failed"
385 );
386 break;
387 }
388 sleep(Duration::from_millis(200)).await;
389 }
390 }
391
392 sleep(Duration::from_millis(self.step_delay_ms)).await;
394 }
395
396 warn!("agent: max rounds exceeded");
398 self.emitter.emit(
399 "agent.error",
400 json!({
401 "error": "max rounds exceeded",
402 "round": self.max_rounds,
403 }),
404 );
405
406 AgentResult {
407 done: false,
408 rounds: self.max_rounds,
409 extracted,
410 label: last_label,
411 }
412 }
413}
414
415pub async fn execute_action(adapter: &ProtocolAdapter, action: &AgentAction) -> Result<()> {
423 match action {
424 AgentAction::Click(selector) => {
426 let (x, y) = get_element_center(adapter, selector).await?;
427 adapter.click_point(x, y).await
428 }
429 AgentAction::ClickAll(selector) => {
430 let js = format!(
431 r#"(function() {{
432 var els = document.querySelectorAll({sel});
433 return Array.from(els).map(function(el) {{
434 var r = el.getBoundingClientRect();
435 return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
436 }});
437 }})()"#,
438 sel = serde_json::to_string(selector).unwrap_or_default()
439 );
440 let val = adapter.evaluate(&js).await?;
441 if let Some(points) = val.as_array() {
442 for pt in points {
443 let x = pt.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
444 let y = pt.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
445 adapter.click_point(x, y).await?;
446 sleep(Duration::from_millis(100)).await;
447 }
448 }
449 Ok(())
450 }
451 AgentAction::ClickPoint { x, y } => adapter.click_point(*x, *y).await,
452 AgentAction::ClickHold { selector, hold_ms } => {
453 let (x, y) = get_element_center(adapter, selector).await?;
454 adapter.click_hold_point(x, y, *hold_ms).await
455 }
456 AgentAction::ClickHoldPoint { x, y, hold_ms } => {
457 adapter.click_hold_point(*x, *y, *hold_ms).await
458 }
459 AgentAction::DoubleClick(selector) => {
460 let (x, y) = get_element_center(adapter, selector).await?;
461 adapter.double_click_point(x, y).await
462 }
463 AgentAction::DoubleClickPoint { x, y } => adapter.double_click_point(*x, *y).await,
464 AgentAction::RightClick(selector) => {
465 let (x, y) = get_element_center(adapter, selector).await?;
466 adapter.right_click_point(x, y).await
467 }
468 AgentAction::RightClickPoint { x, y } => adapter.right_click_point(*x, *y).await,
469 AgentAction::WaitForAndClick(selector) => {
470 wait_for_element(adapter, selector, 5000).await?;
471 let (x, y) = get_element_center(adapter, selector).await?;
472 adapter.click_point(x, y).await
473 }
474
475 AgentAction::ClickDrag { from, to, .. } => {
477 let (fx, fy) = get_element_center(adapter, from).await?;
478 let (tx, ty) = get_element_center(adapter, to).await?;
479 adapter.drag_point(fx, fy, tx, ty).await
480 }
481 AgentAction::ClickDragPoint {
482 from_x,
483 from_y,
484 to_x,
485 to_y,
486 ..
487 } => adapter.drag_point(*from_x, *from_y, *to_x, *to_y).await,
488
489 AgentAction::Type { value } => adapter.insert_text(value).await,
491 AgentAction::Fill { selector, value } => {
492 let sel_json = serde_json::to_string(selector).unwrap_or_default();
494 let clear_js = format!(
495 r#"(function() {{
496 var el = document.querySelector({sel_json});
497 if (el) {{ el.focus(); el.value = ''; }}
498 }})()"#
499 );
500 adapter.evaluate(&clear_js).await?;
501
502 if let Ok((x, y)) = get_element_center(adapter, selector).await {
504 let _ = adapter.click_point(x, y).await;
505 }
506
507 adapter.insert_text(value).await?;
509
510 let event_js = format!(
512 r#"(function() {{
513 var el = document.querySelector({sel_json});
514 if (el) {{
515 el.dispatchEvent(new Event('input', {{ bubbles: true }}));
516 el.dispatchEvent(new Event('change', {{ bubbles: true }}));
517 }}
518 }})()"#
519 );
520 adapter.evaluate(&event_js).await?;
521 Ok(())
522 }
523 AgentAction::Clear(selector) => {
524 let sel_json = serde_json::to_string(selector).unwrap_or_default();
525 let js = format!("document.querySelector({sel_json}).value = ''");
526 adapter.evaluate(&js).await?;
527 Ok(())
528 }
529 AgentAction::Press(key) => adapter.press_key(key).await,
530 AgentAction::KeyDown(key) => adapter.key_down(key).await,
531 AgentAction::KeyUp(key) => adapter.key_up(key).await,
532
533 AgentAction::Select { selector, value } => {
535 let sel_json = serde_json::to_string(selector).unwrap_or_default();
536 let val_json = serde_json::to_string(value).unwrap_or_default();
537 let js = format!(
538 r#"(function() {{
539 var el = document.querySelector({sel_json});
540 if (el) {{
541 el.value = {val_json};
542 el.dispatchEvent(new Event('change', {{ bubbles: true }}));
543 }}
544 }})()"#
545 );
546 adapter.evaluate(&js).await?;
547 Ok(())
548 }
549 AgentAction::Focus(selector) => {
550 let sel_json = serde_json::to_string(selector).unwrap_or_default();
551 adapter
552 .evaluate(&format!(
553 "document.querySelector({sel_json})?.focus()"
554 ))
555 .await?;
556 Ok(())
557 }
558 AgentAction::Blur(selector) => {
559 let sel_json = serde_json::to_string(selector).unwrap_or_default();
560 adapter
561 .evaluate(&format!(
562 "document.querySelector({sel_json})?.blur()"
563 ))
564 .await?;
565 Ok(())
566 }
567 AgentAction::Hover(selector) => {
568 let (x, y) = get_element_center(adapter, selector).await?;
569 adapter.hover_point(x, y).await
570 }
571 AgentAction::HoverPoint { x, y } => adapter.hover_point(*x, *y).await,
572
573 AgentAction::ScrollY(delta) => {
575 adapter
576 .evaluate(&format!("window.scrollBy(0, {delta})"))
577 .await?;
578 Ok(())
579 }
580 AgentAction::ScrollX(delta) => {
581 adapter
582 .evaluate(&format!("window.scrollBy({delta}, 0)"))
583 .await?;
584 Ok(())
585 }
586 AgentAction::ScrollTo { selector } => {
587 let sel_json = serde_json::to_string(selector).unwrap_or_default();
588 adapter
589 .evaluate(&format!(
590 "document.querySelector({sel_json})?.scrollIntoView({{ behavior: 'smooth', block: 'center' }})"
591 ))
592 .await?;
593 Ok(())
594 }
595 AgentAction::ScrollToPoint { x, y } => {
596 adapter
597 .evaluate(&format!("window.scrollTo({x}, {y})"))
598 .await?;
599 Ok(())
600 }
601 AgentAction::InfiniteScroll(max) => {
602 for _ in 0..*max {
603 adapter
604 .evaluate("window.scrollTo(0, document.body.scrollHeight)")
605 .await?;
606 sleep(Duration::from_millis(500)).await;
607 }
608 Ok(())
609 }
610
611 AgentAction::Wait(ms) => {
613 sleep(Duration::from_millis(*ms)).await;
614 Ok(())
615 }
616 AgentAction::WaitFor(selector) => wait_for_element(adapter, selector, 5000).await,
617 AgentAction::WaitForWithTimeout { selector, timeout } => {
618 wait_for_element(adapter, selector, *timeout).await
619 }
620 AgentAction::WaitForNavigation => {
621 sleep(Duration::from_millis(1000)).await;
622 Ok(())
623 }
624 AgentAction::WaitForDom { timeout, .. } => {
625 sleep(Duration::from_millis(*timeout)).await;
626 Ok(())
627 }
628
629 AgentAction::Navigate(url) => adapter.navigate(url).await,
631 AgentAction::GoBack => {
632 adapter.evaluate("window.history.back()").await?;
633 Ok(())
634 }
635 AgentAction::GoForward => {
636 adapter.evaluate("window.history.forward()").await?;
637 Ok(())
638 }
639 AgentAction::Reload => {
640 adapter.evaluate("window.location.reload()").await?;
641 Ok(())
642 }
643
644 AgentAction::SetViewport {
646 width,
647 height,
648 device_scale_factor,
649 mobile,
650 } => {
651 adapter
652 .set_viewport(
653 *width,
654 *height,
655 device_scale_factor.unwrap_or(2.0),
656 mobile.unwrap_or(false),
657 )
658 .await
659 }
660
661 AgentAction::Evaluate(code) => {
663 adapter.evaluate(code).await?;
664 Ok(())
665 }
666
667 AgentAction::Screenshot => Ok(()),
669 }
670}
671
672async fn get_element_center(adapter: &ProtocolAdapter, selector: &str) -> Result<(f64, f64)> {
678 let sel_json = serde_json::to_string(selector).unwrap_or_default();
679 let js = format!(
680 r#"(function() {{
681 var el = document.querySelector({sel_json});
682 if (!el) return null;
683 el.scrollIntoView({{ block: 'center', behavior: 'instant' }});
684 var r = el.getBoundingClientRect();
685 return {{ x: r.x + r.width / 2, y: r.y + r.height / 2 }};
686 }})()"#
687 );
688
689 let result = adapter.evaluate(&js).await?;
690 if result.is_null() {
691 return Err(SpiderError::Other(format!(
692 "Element not found: {selector}"
693 )));
694 }
695
696 let x = result
697 .get("x")
698 .and_then(|v| v.as_f64())
699 .ok_or_else(|| SpiderError::Other(format!("Missing x for element: {selector}")))?;
700 let y = result
701 .get("y")
702 .and_then(|v| v.as_f64())
703 .ok_or_else(|| SpiderError::Other(format!("Missing y for element: {selector}")))?;
704
705 Ok((x, y))
706}
707
708async fn wait_for_element(adapter: &ProtocolAdapter, selector: &str, timeout_ms: u64) -> Result<()> {
710 let interval = 100u64;
711 let max_iter = (timeout_ms + interval - 1) / interval;
712 let sel_json = serde_json::to_string(selector).unwrap_or_default();
713 let check_js = format!("!!document.querySelector({sel_json})");
714
715 for _ in 0..max_iter {
716 let found = adapter.evaluate(&check_js).await?;
717 if found.as_bool().unwrap_or(false) {
718 return Ok(());
719 }
720 sleep(Duration::from_millis(interval)).await;
721 }
722
723 Err(SpiderError::Timeout(format!(
724 "Timeout waiting for element: {selector}"
725 )))
726}
727
728#[cfg(test)]
729mod tests {
730 use super::*;
731
732 #[test]
733 fn agent_scope_default_is_browser() {
734 assert_eq!(AgentScope::default(), AgentScope::Browser);
735 }
736
737 #[test]
738 fn agent_options_default_scope_is_browser() {
739 assert_eq!(AgentOptions::default().scope, AgentScope::Browser);
740 }
741
742 #[test]
743 fn guardrail_js_is_idempotent_guarded() {
744 assert!(GUARDRAIL_JS.contains("__spiderPageScopeGuard"));
745 assert!(GUARDRAIL_JS.contains("window.open"));
746 assert!(GUARDRAIL_JS.contains("target=\"_blank\""));
747 }
748}