1use std::sync::Arc;
2use std::time::Instant;
3
4use chrono::Utc;
5use ras_cdp::BrowserPort;
6use ras_dom::DomExtractor;
7use ras_errors::AppError;
8use ras_events::EventBus;
9use ras_llm::{ChatMessage, ChatResponse, InvokeOptions, LlmClient};
10use ras_tools::domain::registry::{ActionRegistry, ToolContext};
11use ras_types::{ActionResult, StepId, TargetId};
12use url::Url;
13
14use crate::application::compute_action_hash::compute_action_hash;
15use crate::application::detect_loop::{build_budget_warning, build_loop_nudge};
16use crate::application::fallback_llm::should_switch_to_fallback;
17use crate::application::parse_output::parse_agent_output;
18use crate::application::run_step_log::{log_action_err, log_action_ok, log_decision};
19use crate::domain::agent_history::StepRecord;
20use crate::domain::loop_detector::ActionLoopDetector;
21use crate::domain::step_metadata::StepMetadata;
22
23pub struct RunStep {
24 primary_llm: Arc<dyn LlmClient>,
25 fallback_llm: Option<Arc<dyn LlmClient>>,
26 registry: Arc<ActionRegistry>,
27 browser: Arc<dyn BrowserPort>,
28 events: Arc<dyn EventBus>,
29 dom_extractor: Option<Arc<dyn DomExtractor>>,
30 bound_target: Option<TargetId>,
31}
32
33impl RunStep {
34 #[must_use]
35 pub fn new(
36 primary: Arc<dyn LlmClient>,
37 fallback: Option<Arc<dyn LlmClient>>,
38 registry: Arc<ActionRegistry>,
39 browser: Arc<dyn BrowserPort>,
40 events: Arc<dyn EventBus>,
41 dom_extractor: Option<Arc<dyn DomExtractor>>,
42 bound_target: Option<TargetId>,
43 ) -> Self {
44 Self {
45 primary_llm: primary,
46 fallback_llm: fallback,
47 registry,
48 browser,
49 events,
50 dom_extractor,
51 bound_target,
52 }
53 }
54
55 pub async fn execute(
56 &self,
57 step: StepId,
58 max_steps: u32,
59 prompt: Vec<ChatMessage>,
60 detector: &mut ActionLoopDetector,
61 ) -> Result<StepRecord, AppError> {
62 let started = Instant::now();
63 let mut messages = prompt;
64 if let Some(nudge) = build_loop_nudge(detector) {
65 messages.push(nudge);
66 }
67 if let Some(warn) = build_budget_warning(step.0, max_steps) {
68 messages.push(warn);
69 }
70 let response = self.invoke_with_fallback(messages).await?;
71 let output = parse_agent_output(&response)?;
72 log_decision(step.0, &output);
73
74 let target = match &self.bound_target {
75 Some(t) => Some(t.clone()),
76 None => self.browser.focused_target().await.ok(),
77 };
78 let page_url = match &target {
79 Some(t) => self
80 .browser
81 .evaluate(t, "location.href")
82 .await
83 .ok()
84 .and_then(|v| v.as_str().and_then(|s| Url::parse(s).ok())),
85 None => None,
86 };
87
88 let pre_clickables: Arc<Vec<ras_dom::ClickableElement>> =
89 match (&self.dom_extractor, &target) {
90 (Some(extractor), Some(t)) => extractor
91 .snapshot(t)
92 .await
93 .ok()
94 .map(|s| Arc::new(s.clickables))
95 .unwrap_or_else(|| Arc::new(Vec::new())),
96 _ => Arc::new(Vec::new()),
97 };
98
99 let mut results = Vec::new();
100 for action in &output.action {
101 detector.record_action(compute_action_hash(action));
102 let Some(reg) = self.registry.get(&action.name) else {
103 results.push(ActionResult::err(format!(
104 "unknown action: {}",
105 action.name.0
106 )));
107 break;
108 };
109 let ctx = ToolContext {
110 target: target.clone(),
111 browser: self.browser.clone(),
112 events: self.events.clone(),
113 page_url: page_url.clone(),
114 available_files: Vec::new(),
115 clickables: pre_clickables.clone(),
116 };
117 match reg.handler.execute(action.parameters.clone(), ctx).await {
118 Ok(r) => {
119 let terminates = reg.metadata.terminates_sequence;
120 let is_done = r.is_done;
121 let is_err = r.is_error();
122 log_action_ok(step.0, action, &r);
123 results.push(r);
124 if terminates || is_done || is_err {
125 break;
126 }
127 }
128 Err(e) => {
129 log_action_err(step.0, action, &e.to_string());
130 results.push(ActionResult::err(e.to_string()));
131 break;
132 }
133 }
134 }
135
136 let summary = match (&self.dom_extractor, &target) {
137 (Some(extractor), Some(t)) => match extractor.snapshot(t).await {
138 Ok(s) => Some(s),
139 Err(e) => {
140 tracing::warn!(error = %e, "dom snapshot failed; continuing without grounding");
141 None
142 }
143 },
144 _ => None,
145 };
146
147 let metadata = StepMetadata {
148 duration_ms: started.elapsed().as_millis() as u64,
149 step_interval_ms: None,
150 usage: response.usage,
151 model: Some(response.model.clone()),
152 fallback_used: false,
153 };
154 Ok(StepRecord {
155 step,
156 started_at: Utc::now(),
157 url: page_url,
158 output,
159 results,
160 metadata,
161 summary,
162 })
163 }
164
165 async fn invoke_with_fallback(
166 &self,
167 messages: Vec<ChatMessage>,
168 ) -> Result<ChatResponse, AppError> {
169 let opts = InvokeOptions::default();
170 match self
171 .primary_llm
172 .ainvoke(messages.clone(), opts.clone())
173 .await
174 {
175 Ok(r) => Ok(r),
176 Err(e) if should_switch_to_fallback(&e) => match &self.fallback_llm {
177 Some(fb) => fb.ainvoke(messages, opts).await,
178 None => Err(e),
179 },
180 Err(e) => Err(e),
181 }
182 }
183}
184
185#[must_use]
186pub fn done_result(text: impl Into<String>) -> ActionResult {
187 ActionResult::done(text)
188}