Skip to main content

ras_agent/application/
detect_loop.rs

1use ras_llm::ChatMessage;
2
3use crate::domain::loop_detector::ActionLoopDetector;
4
5#[must_use]
6pub fn build_loop_nudge(detector: &ActionLoopDetector) -> Option<ChatMessage> {
7    let mut parts: Vec<String> = Vec::new();
8    if detector.action_loop_detected() {
9        parts.push(
10            "Heads up: the same action repeated several times. Try a different element or strategy."
11                .into(),
12        );
13    }
14    if detector.page_stagnation_detected() {
15        parts.push(
16            "Heads up: the page state has not changed for several steps. Reassess your approach."
17                .into(),
18        );
19    }
20    if parts.is_empty() {
21        return None;
22    }
23    Some(ChatMessage::system(parts.join("\n\n")))
24}
25
26#[must_use]
27pub fn build_budget_warning(step: u32, max_steps: u32) -> Option<ChatMessage> {
28    if max_steps == 0 {
29        return None;
30    }
31    let pct = (step * 100) / max_steps.max(1);
32    if pct < 95 {
33        return None;
34    }
35    Some(ChatMessage::system(format!(
36        "You are at {pct}% of your step budget. Wrap up: call done if you have an answer, or pivot decisively."
37    )))
38}
39
40/// One-time re-prompt issued the step AFTER an empty action list: the model put
41/// its intent in `next_goal`/memory instead of emitting an action. It must emit
42/// exactly one action — or, if the record it was looking for is genuinely not
43/// found, call `done` with a not-found result — BEFORE a second empty trips the
44/// stall abort. Not a salvage: nothing is read out of `next_goal`.
45#[must_use]
46pub fn build_empty_action_nudge(prev_empty: bool) -> Option<ChatMessage> {
47    if !prev_empty {
48        return None;
49    }
50    Some(ChatMessage::system(
51        "Your previous response had NO action — that is not allowed. You MUST emit exactly one \
52         action this step. If the record you were searching for is genuinely not found, call the \
53         `done` action with a not-found result (its `text` set to a JSON object such as \
54         {\"found\": false}). Do NOT put your decision only in next_goal, memory, or the plan.",
55    ))
56}
57
58#[cfg(test)]
59mod tests {
60    use super::build_empty_action_nudge;
61    use ras_llm::ChatMessage;
62
63    #[test]
64    fn no_nudge_when_previous_step_had_an_action() {
65        assert!(build_empty_action_nudge(false).is_none());
66    }
67
68    #[test]
69    fn nudge_after_empty_demands_action_or_done_not_found() {
70        let text = match build_empty_action_nudge(true) {
71            Some(ChatMessage::System(s)) => s.content,
72            _ => String::new(),
73        };
74        assert!(!text.is_empty(), "an empty action must trigger a re-prompt");
75        let lo = text.to_lowercase();
76        assert!(lo.contains("action"), "demands an action");
77        assert!(lo.contains("done"), "offers the done not-found escape");
78        assert!(
79            lo.contains("next_goal") || lo.contains("not found"),
80            "addresses the next_goal stall"
81        );
82    }
83}