Skip to main content

agent_base/engine/
react_loop_guard.rs

1use async_trait::async_trait;
2
3// Re-export from agent-types (single source of truth).
4pub use agent_types::{GuardCtx, GuardDecision};
5
6use crate::engine::runtime::NoticeHandle;
7
8/// React Loop Guard trait — single unified entry point.
9///
10/// Runtime builds GuardCtx (with scene hints), guard decides what to do.
11/// The guard has full control: it can trust the hints or re-detect.
12#[async_trait]
13pub trait ReactLoopGuard: Send + Sync {
14    /// Unified entry point — guard judges the scene and returns a decision.
15    async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision;
16
17    /// Callback when model calls a tool (new)
18    ///
19    /// Default implementation: returns Complete (let other logic continue)
20    ///
21    /// Usage:
22    /// - DefaultGuard can return RestoreThinking here
23    /// - Other guards can record tool call history
24    ///
25    /// Note: This callback is called before tool execution, Guard cannot prevent tool execution
26    async fn on_tool_call(&self, _ctx: &GuardCtx) -> GuardDecision {
27        GuardDecision::Complete
28    }
29
30    /// Receive the engine's notice handle (Batch E). Called once by the
31    /// runtime when the guard is registered, before any run starts. Guards
32    /// that want to surface display-only notices (e.g. judge degradation)
33    /// store the handle; the default implementation ignores it, so existing
34    /// implementers are unaffected.
35    fn set_notice(&self, _handle: NoticeHandle) {}
36}
37
38/// Default guard — fails on degenerate states, completes on normal flow.
39///
40/// This is the default guard injected when no custom guard is set.
41/// It provides basic safety: reasoning-only and empty responses fail,
42/// text-only responses complete normally.
43pub 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        // reasoning-only → Fail
101        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        // empty response → Fail
126        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        // text-only → Complete
135        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        // no flags → Complete
145        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}