agent_base/engine/
react_loop_guard.rs1use async_trait::async_trait;
2
3pub use agent_types::{GuardCtx, GuardDecision};
5
6use crate::engine::runtime::NoticeHandle;
7
8#[async_trait]
13pub trait ReactLoopGuard: Send + Sync {
14 async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision;
16
17 async fn on_tool_call(&self, _ctx: &GuardCtx) -> GuardDecision {
27 GuardDecision::Complete
28 }
29
30 fn set_notice(&self, _handle: NoticeHandle) {}
36}
37
38pub struct NoopGuard;
44
45#[async_trait]
46impl ReactLoopGuard for NoopGuard {
47 async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision {
48 if ctx.is_reasoning_only || ctx.is_empty_response {
49 GuardDecision::Fail {
50 error: if ctx.is_reasoning_only {
51 "model produced only reasoning, no output".to_string()
52 } else {
53 "model returned empty response".to_string()
54 },
55 }
56 } else {
57 GuardDecision::Complete
58 }
59 }
60}
61
62#[cfg(test)]
63mod tests {
64 use super::*;
65 use agent_types::{FinishReason, SessionId};
66
67 #[tokio::test]
68 async fn test_noop_guard_returns_complete() {
69 let guard = NoopGuard;
70 let ctx = GuardCtx {
71 session_id: SessionId {
72 id: 1,
73 external_id: None,
74 },
75 turn_count: 1,
76 user_input: "test".to_string(),
77 model_response: "response".to_string(),
78 finish_reason: FinishReason::Stop,
79 available_tools: vec![],
80 reasoning_only_strikes: 0,
81 empty_response_strikes: 0,
82 run_has_tool_calls: false,
83 last_tool_calls_invalid: false,
84 all_user_inputs: vec!["test".to_string()],
85 is_reasoning_only: false,
86 is_empty_response: false,
87 is_text_only: false,
88 thinking_disabled: false,
89 original_thinking_enabled: true,
90 remaining_turns: 50,
91 };
92
93 assert!(matches!(guard.on_turn(&ctx).await, GuardDecision::Complete));
94 }
95
96 #[tokio::test]
97 async fn test_noop_guard_handles_degenerate_states() {
98 let guard = NoopGuard;
99
100 let ctx = GuardCtx {
102 session_id: SessionId::new(1),
103 turn_count: 1,
104 user_input: "test".to_string(),
105 model_response: "".to_string(),
106 finish_reason: FinishReason::Stop,
107 available_tools: vec![],
108 reasoning_only_strikes: 1,
109 empty_response_strikes: 0,
110 run_has_tool_calls: false,
111 last_tool_calls_invalid: false,
112 all_user_inputs: vec!["test".to_string()],
113 is_reasoning_only: true,
114 is_empty_response: false,
115 is_text_only: false,
116 thinking_disabled: false,
117 original_thinking_enabled: true,
118 remaining_turns: 50,
119 };
120 assert!(matches!(
121 guard.on_turn(&ctx).await,
122 GuardDecision::Fail { .. }
123 ));
124
125 let mut ctx2 = ctx.clone();
127 ctx2.is_reasoning_only = false;
128 ctx2.is_empty_response = true;
129 assert!(matches!(
130 guard.on_turn(&ctx2).await,
131 GuardDecision::Fail { .. }
132 ));
133
134 let mut ctx3 = ctx.clone();
136 ctx3.is_reasoning_only = false;
137 ctx3.is_empty_response = false;
138 ctx3.is_text_only = true;
139 assert!(matches!(
140 guard.on_turn(&ctx3).await,
141 GuardDecision::Complete
142 ));
143
144 let mut ctx4 = ctx.clone();
146 ctx4.is_reasoning_only = false;
147 ctx4.is_text_only = false;
148 assert!(matches!(
149 guard.on_turn(&ctx4).await,
150 GuardDecision::Complete
151 ));
152 }
153}