Skip to main content

ras_agent/application/
run_agent.rs

1use 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(&reg.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(&reg.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}