Skip to main content

ras_agent/application/
render_step_message.rs

1use ras_llm::{ChatMessage, ContentPart};
2
3use crate::application::clickable_map::render_clickable_map;
4use crate::domain::agent_history::StepRecord;
5
6const TEXT_BUDGET: usize = 480;
7const ERROR_BUDGET: usize = 240;
8const SCREENSHOT_MEDIA_TYPE: &str = "image/png";
9
10pub(crate) fn render_step_message(step: &StepRecord) -> Option<ChatMessage> {
11    if step.results.is_empty() && step.summary.is_none() {
12        return None;
13    }
14    let mut text = format!("Step {} result:\n", step.step.0);
15    if let Some(url) = &step.url {
16        text.push_str(&format!("url: {url}\n"));
17    }
18    if !step.results.is_empty() {
19        text.push_str("action results:\n");
20        for (i, r) in step.results.iter().enumerate() {
21            text.push_str(&format!("  [{i}]"));
22            if r.is_done {
23                text.push_str(" done");
24            }
25            if let Some(err) = &r.error {
26                text.push_str(&format!(" error: {}", truncate(err, ERROR_BUDGET)));
27            } else if let Some(c) = &r.extracted_content {
28                text.push_str(&format!(" {}", truncate(c, TEXT_BUDGET)));
29            }
30            text.push('\n');
31        }
32    }
33    if let Some(summary) = &step.summary {
34        text.push_str(&render_clickable_map(summary));
35    }
36
37    let mut parts: Vec<ContentPart> = Vec::with_capacity(2);
38    parts.push(ContentPart::Text { text });
39    if let Some(b64) = step
40        .summary
41        .as_ref()
42        .and_then(|s| s.screenshot_b64.as_ref())
43    {
44        parts.push(ContentPart::ImageBase64 {
45            media_type: SCREENSHOT_MEDIA_TYPE.into(),
46            data: b64.clone(),
47        });
48    } else {
49        for r in &step.results {
50            for img_b64 in &r.images {
51                parts.push(ContentPart::ImageBase64 {
52                    media_type: SCREENSHOT_MEDIA_TYPE.into(),
53                    data: img_b64.clone(),
54                });
55            }
56        }
57    }
58    Some(ChatMessage::user_parts(parts))
59}
60
61fn truncate(s: &str, max: usize) -> String {
62    if s.chars().count() <= max {
63        s.to_string()
64    } else {
65        let mut out: String = s.chars().take(max).collect();
66        out.push('…');
67        out
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use chrono::Utc;
75    use ras_llm::ChatMessage;
76    use ras_types::{ActionResult, StepId};
77
78    use crate::domain::agent_output::{AgentBrain, AgentOutput};
79    use crate::domain::step_metadata::StepMetadata;
80
81    fn step(results: Vec<ActionResult>) -> StepRecord {
82        StepRecord {
83            step: StepId(7),
84            started_at: Utc::now(),
85            url: Some("https://example.com/login".parse().expect("test url")),
86            output: AgentOutput {
87                current_state: AgentBrain {
88                    evaluation_previous_goal: String::new(),
89                    memory: String::new(),
90                    next_goal: String::new(),
91                },
92                action: vec![],
93                plan: None,
94                current_plan_item: None,
95            },
96            results,
97            metadata: StepMetadata::default(),
98            summary: None,
99        }
100    }
101
102    #[test]
103    fn empty_results_yields_no_message() {
104        assert!(render_step_message(&step(vec![])).is_none());
105    }
106
107    #[test]
108    fn text_only_result_emits_text_part_only() {
109        let r = ActionResult::ok("clicked login button");
110        let msg = render_step_message(&step(vec![r])).expect("msg");
111        let ChatMessage::User(u) = msg else {
112            panic!("expected user");
113        };
114        assert_eq!(u.content.len(), 1);
115        match &u.content[0] {
116            ContentPart::Text { text } => {
117                assert!(text.contains("Step 7 result:"));
118                assert!(text.contains("url: https://example.com/login"));
119                assert!(text.contains("clicked login button"));
120            }
121            other => panic!("expected text part, got {other:?}"),
122        }
123    }
124
125    #[test]
126    fn screenshot_result_emits_text_plus_image_part() {
127        let r = ActionResult::ok("captured screenshot").with_image("AAAA");
128        let msg = render_step_message(&step(vec![r])).expect("msg");
129        let ChatMessage::User(u) = msg else {
130            panic!("expected user");
131        };
132        assert_eq!(u.content.len(), 2);
133        assert!(matches!(&u.content[0], ContentPart::Text { .. }));
134        match &u.content[1] {
135            ContentPart::ImageBase64 { media_type, data } => {
136                assert_eq!(media_type, "image/png");
137                assert_eq!(data, "AAAA");
138            }
139            other => panic!("expected image part, got {other:?}"),
140        }
141    }
142
143    #[test]
144    fn multiple_images_across_results_all_attached() {
145        let r1 = ActionResult::ok("step a").with_image("AAAA");
146        let r2 = ActionResult::ok("step b")
147            .with_image("BBBB")
148            .with_image("CCCC");
149        let msg = render_step_message(&step(vec![r1, r2])).expect("msg");
150        let ChatMessage::User(u) = msg else {
151            panic!("expected user");
152        };
153        let images: Vec<&String> = u
154            .content
155            .iter()
156            .filter_map(|p| match p {
157                ContentPart::ImageBase64 { data, .. } => Some(data),
158                _ => None,
159            })
160            .collect();
161        assert_eq!(images, vec!["AAAA", "BBBB", "CCCC"]);
162    }
163
164    #[test]
165    fn error_result_emits_error_in_text() {
166        let r = ActionResult::err("element not found");
167        let msg = render_step_message(&step(vec![r])).expect("msg");
168        let ChatMessage::User(u) = msg else {
169            panic!("expected user");
170        };
171        match &u.content[0] {
172            ContentPart::Text { text } => assert!(text.contains("error: element not found")),
173            _ => panic!("expected text"),
174        }
175    }
176}