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