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