Skip to main content

agent_works/guard/
default.rs

1use agent_base::engine::react_loop_guard::{GuardCtx, GuardDecision, ReactLoopGuard};
2use agent_base::llm_trait::LlmProvider;
3use async_trait::async_trait;
4use std::sync::Arc;
5
6use super::config::{DefaultGuardConfig, ReasoningOnlyAction};
7use super::judge::call_completion_judge;
8
9/// Default guard implementation
10///
11/// Does not manage its own state; uses RunState information from GuardCtx.
12pub struct DefaultGuard {
13    config: DefaultGuardConfig,
14    llm_client: Option<Arc<dyn LlmProvider>>,
15}
16
17impl DefaultGuard {
18    pub fn new(config: DefaultGuardConfig) -> Self {
19        Self {
20            config,
21            llm_client: None,
22        }
23    }
24
25    /// Create a new DefaultGuard with LLM client for judge functionality
26    pub fn with_llm_client(config: DefaultGuardConfig, llm_client: Arc<dyn LlmProvider>) -> Self {
27        Self {
28            config,
29            llm_client: Some(llm_client),
30        }
31    }
32
33    // ── Scene handlers ──────────────────────────────────────────────────
34
35    async fn handle_reasoning_only(&self, ctx: &GuardCtx) -> GuardDecision {
36        let strikes = ctx.reasoning_only_strikes;
37
38        match self.config.reasoning_only_action {
39            ReasoningOnlyAction::Fail => {
40                // Default behavior: fail after max strikes
41                if strikes >= self.config.reasoning_only_max_strikes {
42                    return GuardDecision::Fail {
43                        error: "model produced only reasoning across multiple turns".to_string(),
44                    };
45                }
46
47                GuardDecision::Continue {
48                    nudge: Some(self.config.reasoning_only_nudge.clone()),
49                }
50            }
51            ReasoningOnlyAction::DisableThinking => {
52                // New behavior: disable thinking after max strikes
53                if strikes >= self.config.reasoning_only_max_strikes {
54                    // Check if thinking is already disabled
55                    if ctx.thinking_disabled {
56                        // Thinking is already disabled but still reasoning-only → fail
57                        return GuardDecision::Fail {
58                            error: "model produced only reasoning even after thinking was disabled"
59                                .to_string(),
60                        };
61                    }
62
63                    // Disable thinking and continue
64                    return GuardDecision::DisableThinking {
65                        nudge: self.config.disable_thinking_nudge.clone(),
66                    };
67                }
68
69                GuardDecision::Continue {
70                    nudge: Some(self.config.reasoning_only_nudge.clone()),
71                }
72            }
73        }
74    }
75
76    async fn handle_empty_response(&self, ctx: &GuardCtx) -> GuardDecision {
77        let strikes = ctx.empty_response_strikes;
78
79        if strikes >= self.config.empty_response_max_strikes {
80            return GuardDecision::Fail {
81                error: "model returned empty responses repeatedly".to_string(),
82            };
83        }
84
85        GuardDecision::Continue {
86            nudge: Some(self.config.empty_response_nudge.clone()),
87        }
88    }
89
90    async fn handle_text_only(&self, ctx: &GuardCtx) -> GuardDecision {
91        let input_len = ctx.user_input.chars().count();
92        let output_len = ctx.model_response.chars().count();
93
94        // Short-response detection: user asked a substantial question but the
95        // model gave a very short answer — likely incomplete.
96        let is_short_response = self.config.detect_short_response
97            && input_len > self.config.short_response_min_input
98            && output_len < self.config.short_response_max_output
99            && input_len > output_len;
100
101        if is_short_response {
102            tracing::info!(
103                input_chars = input_len,
104                output_chars = output_len,
105                min_input = self.config.short_response_min_input,
106                max_output = self.config.short_response_max_output,
107                run_has_tool_calls = ctx.run_has_tool_calls,
108                "short response detected in text-only branch"
109            );
110
111            if ctx.run_has_tool_calls && self.config.use_llm_judge {
112                // Skip LLM judge for very large inputs — judge would be too slow
113                const INPUT_LEN_LIMIT: usize = 10_000;
114                if input_len > INPUT_LEN_LIMIT {
115                    tracing::info!(
116                        input_chars = input_len,
117                        input_limit = INPUT_LEN_LIMIT,
118                        "skipping LLM judge — user input too large, trusting model"
119                    );
120                    return GuardDecision::Complete;
121                }
122                // Short response after tools — call judge to verify completion
123                match call_completion_judge(
124                    self.llm_client.as_ref(),
125                    &ctx.user_input,
126                    &ctx.model_response,
127                    &ctx.all_user_inputs,
128                    self.config.judge_fail_open,
129                    self.config.judge_timeout_secs,
130                    self.config.recent_user_count,
131                )
132                .await
133                {
134                    Ok(judge) => {
135                        if judge.done {
136                            GuardDecision::Complete
137                        } else {
138                            GuardDecision::Continue {
139                                nudge: Some(format!(
140                                    "Your answer is incomplete: {}. Continue working on the task.",
141                                    judge.reason
142                                )),
143                            }
144                        }
145                    }
146                    Err(e) => {
147                        // Judge failed — behavior depends on judge_fail_open config
148                        tracing::warn!("completion judge failed: {}", e);
149                        if self.config.judge_fail_open {
150                            GuardDecision::Complete
151                        } else {
152                            GuardDecision::Continue {
153                                nudge: Some(
154                                    "Cannot verify task completion, please continue working."
155                                        .to_string(),
156                                ),
157                            }
158                        }
159                    }
160                }
161            } else {
162                // Short response without tools or judge disabled — nudge
163                GuardDecision::Continue {
164                    nudge: Some(self.config.short_response_nudge.clone()),
165                }
166            }
167        } else if ctx.run_has_tool_calls && self.config.use_llm_judge {
168            // Non-short response after tools — check skip threshold
169            if output_len >= self.config.judge_skip_threshold {
170                tracing::debug!(
171                    response_chars = output_len,
172                    threshold = self.config.judge_skip_threshold,
173                    "text-only response long enough, skipping judge"
174                );
175                return GuardDecision::Complete;
176            }
177
178            // Skip LLM judge for very large inputs — judge would be too slow
179            const INPUT_LEN_LIMIT: usize = 10_000;
180            if input_len > INPUT_LEN_LIMIT {
181                tracing::info!(
182                    input_chars = input_len,
183                    input_limit = INPUT_LEN_LIMIT,
184                    "skipping LLM judge — user input too large, trusting model"
185                );
186                return GuardDecision::Complete;
187            }
188
189            tracing::info!(
190                response_chars = output_len,
191                threshold = self.config.judge_skip_threshold,
192                "text-only response short, calling judge"
193            );
194            match call_completion_judge(
195                self.llm_client.as_ref(),
196                &ctx.user_input,
197                &ctx.model_response,
198                &ctx.all_user_inputs,
199                self.config.judge_fail_open,
200                self.config.judge_timeout_secs,
201                self.config.recent_user_count,
202            )
203            .await
204            {
205                Ok(judge) => {
206                    if judge.done {
207                        GuardDecision::Complete
208                    } else {
209                        GuardDecision::Continue {
210                            nudge: Some(format!(
211                                "Your answer is incomplete: {}. Continue working on the task.",
212                                judge.reason
213                            )),
214                        }
215                    }
216                }
217                Err(e) => {
218                    // Judge failed — behavior depends on judge_fail_open config
219                    tracing::warn!("completion judge failed: {}", e);
220                    if self.config.judge_fail_open {
221                        GuardDecision::Complete
222                    } else {
223                        GuardDecision::Continue {
224                            nudge: Some(
225                                "Cannot verify task completion, please continue working."
226                                    .to_string(),
227                            ),
228                        }
229                    }
230                }
231            }
232        } else {
233            GuardDecision::Complete
234        }
235    }
236}
237
238#[async_trait]
239impl ReactLoopGuard for DefaultGuard {
240    async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision {
241        if ctx.is_reasoning_only {
242            self.handle_reasoning_only(ctx).await
243        } else if ctx.is_empty_response {
244            self.handle_empty_response(ctx).await
245        } else if ctx.is_text_only {
246            self.handle_text_only(ctx).await
247        } else {
248            GuardDecision::Complete
249        }
250    }
251
252    async fn on_tool_call(&self, ctx: &GuardCtx) -> GuardDecision {
253        // Restore thinking when:
254        // 1. Thinking is currently disabled (by guard)
255        // 2. Original thinking was enabled (user wanted thinking)
256        // 3. Model calls a tool (showing it's working again)
257        if ctx.thinking_disabled && ctx.original_thinking_enabled {
258            tracing::info!(
259                session_id = ctx.session_id.id,
260                turn = ctx.turn_count,
261                "tool call detected while thinking disabled, restoring thinking"
262            );
263            return GuardDecision::RestoreThinking;
264        }
265
266        GuardDecision::Complete
267    }
268}