ras_agent/application/
render_step_message.rs1use 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}