1use std::collections::VecDeque;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum ReactState {
5 Thinking,
6 Acting,
7 Observing,
8 Persisting,
9 Idle,
10 Done,
11}
12
13#[derive(Debug, Clone)]
14pub enum ReactAction {
15 Think,
16 Act { tool_name: String, params: String },
17 Observe,
18 Persist,
19 NoOp,
20 Finish,
21}
22
23const IDLE_THRESHOLD: usize = 3;
24const LOOP_DETECTION_WINDOW: usize = 3;
25
26pub struct AgentLoop {
27 pub state: ReactState,
28 pub turn_count: usize,
29 pub max_turns: usize,
30 idle_count: usize,
31 recent_calls: VecDeque<(String, String)>,
32}
33
34impl AgentLoop {
35 pub fn new(max_turns: usize) -> Self {
36 Self {
37 state: ReactState::Idle,
38 turn_count: 0,
39 max_turns,
40 idle_count: 0,
41 recent_calls: VecDeque::with_capacity(LOOP_DETECTION_WINDOW + 1),
42 }
43 }
44
45 pub fn transition(&mut self, action: ReactAction) -> ReactState {
46 match action {
47 ReactAction::Think => {
48 self.turn_count += 1;
51 if self.turn_count > self.max_turns {
52 self.state = ReactState::Done;
53 return self.state;
54 }
55 self.idle_count = 0;
56 self.state = ReactState::Thinking;
57 }
58 ReactAction::Act { tool_name, params } => {
59 self.idle_count = 0;
60 if self.is_looping(&tool_name, ¶ms) {
63 tracing::warn!(tool = %tool_name, "agent loop detected, forcing Done");
64 self.state = ReactState::Done;
65 } else {
66 self.state = ReactState::Acting;
67 }
68 self.recent_calls
69 .push_back((tool_name.clone(), params.clone()));
70 if self.recent_calls.len() > LOOP_DETECTION_WINDOW {
71 self.recent_calls.pop_front();
72 }
73 }
74 ReactAction::Observe => {
75 self.idle_count = 0;
76 self.state = ReactState::Observing;
77 }
78 ReactAction::Persist => {
79 self.idle_count = 0;
80 self.state = ReactState::Persisting;
81 }
82 ReactAction::NoOp => {
83 self.idle_count += 1;
84 if self.idle_count >= IDLE_THRESHOLD {
85 self.state = ReactState::Idle;
86 }
87 }
88 ReactAction::Finish => {
89 self.state = ReactState::Done;
90 }
91 }
92
93 self.state
94 }
95
96 pub fn is_idle(&self) -> bool {
97 self.idle_count >= IDLE_THRESHOLD
98 }
99
100 pub fn is_looping(&self, tool_name: &str, params: &str) -> bool {
103 if self.recent_calls.len() < LOOP_DETECTION_WINDOW {
104 return false;
105 }
106
107 self.recent_calls
108 .iter()
109 .all(|(t, p)| t == tool_name && p == params)
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 #[test]
118 fn state_transitions() {
119 let mut agent = AgentLoop::new(100);
120 assert_eq!(agent.state, ReactState::Idle);
121
122 let s = agent.transition(ReactAction::Think);
123 assert_eq!(s, ReactState::Thinking);
124
125 let s = agent.transition(ReactAction::Act {
126 tool_name: "echo".into(),
127 params: "{}".into(),
128 });
129 assert_eq!(s, ReactState::Acting);
130
131 let s = agent.transition(ReactAction::Observe);
132 assert_eq!(s, ReactState::Observing);
133
134 let s = agent.transition(ReactAction::Persist);
135 assert_eq!(s, ReactState::Persisting);
136
137 let s = agent.transition(ReactAction::Finish);
138 assert_eq!(s, ReactState::Done);
139 }
140
141 #[test]
142 fn idle_detection() {
143 let mut agent = AgentLoop::new(100);
144
145 assert!(!agent.is_idle());
146 agent.transition(ReactAction::NoOp);
147 assert!(!agent.is_idle());
148 agent.transition(ReactAction::NoOp);
149 assert!(!agent.is_idle());
150 agent.transition(ReactAction::NoOp);
151 assert!(agent.is_idle());
152 assert_eq!(agent.state, ReactState::Idle);
153
154 agent.transition(ReactAction::Think);
155 assert!(!agent.is_idle());
156 }
157
158 #[test]
159 fn loop_detection() {
160 let mut agent = AgentLoop::new(100);
161
162 for _ in 0..3 {
163 let s = agent.transition(ReactAction::Act {
164 tool_name: "echo".into(),
165 params: r#"{"msg":"hi"}"#.into(),
166 });
167 assert_eq!(s, ReactState::Acting);
168 }
169
170 assert!(agent.is_looping("echo", r#"{"msg":"hi"}"#));
171 assert!(!agent.is_looping("echo", r#"{"msg":"bye"}"#));
172 assert!(!agent.is_looping("other", r#"{"msg":"hi"}"#));
173
174 let s = agent.transition(ReactAction::Act {
175 tool_name: "echo".into(),
176 params: r#"{"msg":"hi"}"#.into(),
177 });
178 assert_eq!(s, ReactState::Done);
179
180 agent.transition(ReactAction::Act {
181 tool_name: "read".into(),
182 params: "{}".into(),
183 });
184 assert!(!agent.is_looping("echo", r#"{"msg":"hi"}"#));
185 }
186
187 #[test]
188 fn max_turns_forces_done() {
189 let mut agent = AgentLoop::new(2);
190
191 agent.transition(ReactAction::Think);
192 agent.transition(ReactAction::Act {
194 tool_name: "echo".into(),
195 params: "{}".into(),
196 });
197 agent.transition(ReactAction::Observe);
198 assert_eq!(agent.turn_count, 1);
199
200 agent.transition(ReactAction::Think);
201 assert_eq!(agent.turn_count, 2);
202
203 let s = agent.transition(ReactAction::Think);
205 assert_eq!(s, ReactState::Done);
206 }
207}