Skip to main content

ras_agent/application/
run_step.rs

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}