ras_agent/application/
run_agent.rs1use std::sync::Arc;
2
3use ras_cdp::BrowserPort;
4use ras_dom::DomExtractor;
5use ras_errors::AppError;
6use ras_events::EventBus;
7use ras_llm::{ChatMessage, LlmClient};
8use ras_tools::domain::registry::ActionRegistry;
9use ras_types::{AgentId, StepId, TargetId};
10
11use crate::application::render_step_message::render_step_message;
12use crate::application::run_step::RunStep;
13use crate::domain::agent_history::{AgentHistory, AgentHistoryList, StepRecord};
14use crate::domain::loop_detector::ActionLoopDetector;
15
16const AGENT_OUTPUT_SHAPE: &str = r#"{"current_state":{"evaluation_previous_goal":"...","memory":"...","next_goal":"..."},"action":[{"name":"<action_name>","parameters":{...}}]}"#;
17
18pub struct RunAgent {
19 pub agent: AgentId,
20 pub task: String,
21 pub max_steps: u32,
22 pub bound_target: Option<TargetId>,
23 pub primary_llm: Arc<dyn LlmClient>,
24 pub fallback_llm: Option<Arc<dyn LlmClient>>,
25 pub registry: Arc<ActionRegistry>,
26 pub browser: Arc<dyn BrowserPort>,
27 pub events: Arc<dyn EventBus>,
28 pub dom_extractor: Option<Arc<dyn DomExtractor>>,
29}
30
31impl RunAgent {
32 pub fn new(
33 task: impl Into<String>,
34 llm: Arc<dyn LlmClient>,
35 registry: Arc<ActionRegistry>,
36 browser: Arc<dyn BrowserPort>,
37 events: Arc<dyn EventBus>,
38 ) -> Self {
39 Self {
40 agent: AgentId::new(),
41 task: task.into(),
42 max_steps: 25,
43 bound_target: None,
44 primary_llm: llm,
45 fallback_llm: None,
46 registry,
47 browser,
48 events,
49 dom_extractor: None,
50 }
51 }
52
53 #[must_use]
54 pub fn with_target(mut self, target: TargetId) -> Self {
55 self.bound_target = Some(target);
56 self
57 }
58
59 #[must_use]
60 pub fn with_dom_extractor(mut self, extractor: Arc<dyn DomExtractor>) -> Self {
61 self.dom_extractor = Some(extractor);
62 self
63 }
64
65 #[must_use]
66 pub fn with_fallback(mut self, fallback: Arc<dyn LlmClient>) -> Self {
67 self.fallback_llm = Some(fallback);
68 self
69 }
70
71 #[must_use]
72 pub fn with_max_steps(mut self, max_steps: u32) -> Self {
73 self.max_steps = max_steps;
74 self
75 }
76
77 pub async fn execute(self) -> Result<AgentHistoryList, AppError> {
78 let runner = RunStep::new(
79 self.primary_llm.clone(),
80 self.fallback_llm.clone(),
81 self.registry.clone(),
82 self.browser.clone(),
83 self.events.clone(),
84 self.dom_extractor.clone(),
85 self.bound_target.clone(),
86 );
87 let mut detector = ActionLoopDetector::new();
88 let mut history = AgentHistory {
89 agent: self.agent,
90 task: self.task.clone(),
91 steps: Vec::new(),
92 };
93 let mut last_step_ms: Option<u64> = None;
94 let mut empty_streak: u32 = 0;
95 for n in 0..self.max_steps {
96 let prompt = build_prompt(&self.task, &history.steps, &self.registry);
97 let mut record = runner
98 .execute(StepId(n), self.max_steps, prompt, &mut detector)
99 .await?;
100 record.metadata.step_interval_ms = last_step_ms;
101 last_step_ms = Some(record.metadata.duration_ms);
102
103 let done = record.results.iter().any(|r| r.is_done);
104 if record.output.action.is_empty() {
105 empty_streak += 1;
106 tracing::warn!(
107 step = n,
108 "model returned empty action list (streak={empty_streak}); treating as stalled"
109 );
110 } else {
111 empty_streak = 0;
112 }
113
114 history.steps.push(record);
115 if done {
116 break;
117 }
118 if empty_streak >= 2 {
119 tracing::error!("agent stalled: 2 consecutive empty action lists, aborting");
120 break;
121 }
122 }
123 let mut list = AgentHistoryList::default();
124 list.push(history);
125 Ok(list)
126 }
127}
128
129fn build_prompt(task: &str, history: &[StepRecord], registry: &ActionRegistry) -> Vec<ChatMessage> {
130 let catalog = render_action_catalog(registry);
131 let system = format!(
132 "You are a browsing agent. Your task: {task}\n\n\
133 You drive a real browser via the actions listed below. Each step you must \
134 emit ONE JSON object matching this exact shape (no prose, no markdown fences):\n\
135 {AGENT_OUTPUT_SHAPE}\n\n\
136 Available actions:\n{catalog}\n\n\
137 Rules:\n\
138 - Use only action names from the catalog above. Match parameters_schema exactly.\n\
139 - Plan one or two atomic steps at a time; the runtime executes them in order \
140 and feeds results back on the next turn.\n\
141 - Call the `done` action when the task is complete; pass the final answer in \
142 its parameters. Returning an empty action list is treated as a failure."
143 );
144 let mut out = vec![ChatMessage::system(system)];
145 for step in history.iter().rev().take(4).rev() {
146 if let Ok(j) = serde_json::to_string(&step.output) {
147 out.push(ChatMessage::assistant_text(j));
148 }
149 if let Some(msg) = render_step_message(step) {
150 out.push(msg);
151 }
152 }
153 out.push(ChatMessage::user_text(
154 "Decide the next action(s). Respond with the JSON object only.",
155 ));
156 out
157}
158
159fn render_action_catalog(registry: &ActionRegistry) -> String {
160 let mut buf = String::new();
161 for (name, reg) in registry.iter() {
162 let schema =
163 serde_json::to_string(®.metadata.parameters_schema).unwrap_or_else(|_| "{}".into());
164 buf.push_str("- ");
165 buf.push_str(&name.0);
166 buf.push_str(": ");
167 buf.push_str(®.metadata.description);
168 if reg.metadata.terminates_sequence {
169 buf.push_str(" [terminates step]");
170 }
171 buf.push_str("\n parameters_schema: ");
172 buf.push_str(&schema);
173 buf.push('\n');
174 }
175 if buf.is_empty() {
176 buf.push_str("(no actions registered)\n");
177 }
178 buf
179}