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            last_tool_calls_invalid: false,
75            all_user_inputs: vec!["test".to_string()],
76            is_reasoning_only: false,
77            is_empty_response: false,
78            is_text_only: false,
79            thinking_disabled: false,
80            original_thinking_enabled: true,
81            remaining_turns: 50,
82        };
83
84        assert!(matches!(guard.on_turn(&ctx).await, GuardDecision::Complete));
85    }
86
87    #[tokio::test]
88    async fn test_noop_guard_handles_degenerate_states() {
89        let guard = NoopGuard;
90
91        // reasoning-only → Fail
92        let ctx = GuardCtx {
93            session_id: SessionId::new(1),
94            turn_count: 1,
95            user_input: "test".to_string(),
96            model_response: "".to_string(),
97            finish_reason: FinishReason::Stop,
98            available_tools: vec![],
99            reasoning_only_strikes: 1,
100            empty_response_strikes: 0,
101            run_has_tool_calls: false,
102            last_tool_calls_invalid: false,
103            all_user_inputs: vec!["test".to_string()],
104            is_reasoning_only: true,
105            is_empty_response: false,
106            is_text_only: false,
107            thinking_disabled: false,
108            original_thinking_enabled: true,
109            remaining_turns: 50,
110        };
111        assert!(matches!(
112            guard.on_turn(&ctx).await,
113            GuardDecision::Fail { .. }
114        ));
115
116        // empty response → Fail
117        let mut ctx2 = ctx.clone();
118        ctx2.is_reasoning_only = false;
119        ctx2.is_empty_response = true;
120        assert!(matches!(
121            guard.on_turn(&ctx2).await,
122            GuardDecision::Fail { .. }
123        ));
124
125        // text-only → Complete
126        let mut ctx3 = ctx.clone();
127        ctx3.is_reasoning_only = false;
128        ctx3.is_empty_response = false;
129        ctx3.is_text_only = true;
130        assert!(matches!(
131            guard.on_turn(&ctx3).await,
132            GuardDecision::Complete
133        ));
134
135        // no flags → Complete
136        let mut ctx4 = ctx.clone();
137        ctx4.is_reasoning_only = false;
138        ctx4.is_text_only = false;
139        assert!(matches!(
140            guard.on_turn(&ctx4).await,
141            GuardDecision::Complete
142        ));
143    }
144}