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