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