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
6/// React Loop Guard trait — single unified entry point.
7///
8/// Runtime builds GuardCtx (with scene hints), guard decides what to do.
9/// The guard has full control: it can trust the hints or re-detect.
10#[async_trait]
11pub trait ReactLoopGuard: Send + Sync {
12    /// Unified entry point — guard judges the scene and returns a decision.
13    async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision;
14
15    /// Callback when model calls a tool (new)
16    ///
17    /// Default implementation: returns Complete (let other logic continue)
18    ///
19    /// Usage:
20    /// - DefaultGuard can return RestoreThinking here
21    /// - Other guards can record tool call history
22    ///
23    /// Note: This callback is called before tool execution, Guard cannot prevent tool execution
24    async fn on_tool_call(&self, _ctx: &GuardCtx) -> GuardDecision {
25        GuardDecision::Complete
26    }
27}
28
29/// Default guard — fails on degenerate states, completes on normal flow.
30///
31/// This is the default guard injected when no custom guard is set.
32/// It provides basic safety: reasoning-only and empty responses fail,
33/// text-only responses complete normally.
34pub struct NoopGuard;
35
36#[async_trait]
37impl ReactLoopGuard for NoopGuard {
38    async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision {
39        if ctx.is_reasoning_only || ctx.is_empty_response {
40            GuardDecision::Fail {
41                error: if ctx.is_reasoning_only {
42                    "model produced only reasoning, no output".to_string()
43                } else {
44                    "model returned empty response".to_string()
45                },
46            }
47        } else {
48            GuardDecision::Complete
49        }
50    }
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56    use agent_types::{FinishReason, SessionId};
57
58    #[tokio::test]
59    async fn test_noop_guard_returns_complete() {
60        let guard = NoopGuard;
61        let ctx = GuardCtx {
62            session_id: SessionId {
63                id: 1,
64                external_id: None,
65            },
66            turn_count: 1,
67            user_input: "test".to_string(),
68            model_response: "response".to_string(),
69            finish_reason: FinishReason::Stop,
70            available_tools: vec![],
71            reasoning_only_strikes: 0,
72            empty_response_strikes: 0,
73            run_has_tool_calls: false,
74            all_user_inputs: vec!["test".to_string()],
75            is_reasoning_only: false,
76            is_empty_response: false,
77            is_text_only: false,
78            thinking_disabled: false,
79            original_thinking_enabled: true,
80            remaining_turns: 50,
81        };
82
83        assert!(matches!(guard.on_turn(&ctx).await, GuardDecision::Complete));
84    }
85
86    #[tokio::test]
87    async fn test_noop_guard_handles_degenerate_states() {
88        let guard = NoopGuard;
89
90        // reasoning-only → Fail
91        let ctx = GuardCtx {
92            session_id: SessionId::new(1),
93            turn_count: 1,
94            user_input: "test".to_string(),
95            model_response: "".to_string(),
96            finish_reason: FinishReason::Stop,
97            available_tools: vec![],
98            reasoning_only_strikes: 1,
99            empty_response_strikes: 0,
100            run_has_tool_calls: false,
101            all_user_inputs: vec!["test".to_string()],
102            is_reasoning_only: true,
103            is_empty_response: false,
104            is_text_only: false,
105            thinking_disabled: false,
106            original_thinking_enabled: true,
107            remaining_turns: 50,
108        };
109        assert!(matches!(
110            guard.on_turn(&ctx).await,
111            GuardDecision::Fail { .. }
112        ));
113
114        // empty response → Fail
115        let mut ctx2 = ctx.clone();
116        ctx2.is_reasoning_only = false;
117        ctx2.is_empty_response = true;
118        assert!(matches!(
119            guard.on_turn(&ctx2).await,
120            GuardDecision::Fail { .. }
121        ));
122
123        // text-only → Complete
124        let mut ctx3 = ctx.clone();
125        ctx3.is_reasoning_only = false;
126        ctx3.is_empty_response = false;
127        ctx3.is_text_only = true;
128        assert!(matches!(
129            guard.on_turn(&ctx3).await,
130            GuardDecision::Complete
131        ));
132
133        // no flags → Complete
134        let mut ctx4 = ctx.clone();
135        ctx4.is_reasoning_only = false;
136        ctx4.is_text_only = false;
137        assert!(matches!(
138            guard.on_turn(&ctx4).await,
139            GuardDecision::Complete
140        ));
141    }
142}