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        // Session 20260904_efad759c: after the react-side truncation guard
92        // rejected a spawn_agent call, the model replied with a text-only
93        // "success" narrative and the completion judge — which sees only the
94        // user inputs and that narrative — passed it, ending the run with
95        // zero children spawned. A text-only turn that follows rejected tool
96        // calls is definitionally not completion: the work the text describes
97        // never executed. Skip the judge, push the model back to re-issuing.
98        // (Bounded: the react truncation breaker fails the run at its strike
99        // limit, and max_turns still applies.)
100        if ctx.last_tool_calls_invalid {
101            tracing::info!(
102                session_id = ctx.session_id.id,
103                turn = ctx.turn_count,
104                "text-only response after rejected tool calls — not completion, re-issuing"
105            );
106            return GuardDecision::Continue {
107                nudge: Some(
108                    "Your previous tool call was NOT executed — its arguments \
109                     were invalid or truncated. The report you just wrote \
110                     describes work that never happened; do not narrate \
111                     results. Re-issue the tool call with complete, valid \
112                     JSON arguments."
113                        .to_string(),
114                ),
115            };
116        }
117
118        let input_len = ctx.user_input.chars().count();
119        let output_len = ctx.model_response.chars().count();
120
121        // Short-response detection: user asked a substantial question but the
122        // model gave a very short answer — likely incomplete.
123        let is_short_response = self.config.detect_short_response
124            && input_len > self.config.short_response_min_input
125            && output_len < self.config.short_response_max_output
126            && input_len > output_len;
127
128        if is_short_response {
129            tracing::info!(
130                input_chars = input_len,
131                output_chars = output_len,
132                min_input = self.config.short_response_min_input,
133                max_output = self.config.short_response_max_output,
134                run_has_tool_calls = ctx.run_has_tool_calls,
135                "short response detected in text-only branch"
136            );
137
138            if ctx.run_has_tool_calls && self.config.use_llm_judge {
139                // Skip LLM judge for very large inputs — judge would be too slow
140                const INPUT_LEN_LIMIT: usize = 10_000;
141                if input_len > INPUT_LEN_LIMIT {
142                    tracing::info!(
143                        input_chars = input_len,
144                        input_limit = INPUT_LEN_LIMIT,
145                        "skipping LLM judge — user input too large, trusting model"
146                    );
147                    return GuardDecision::Complete;
148                }
149                // Short response after tools — call judge to verify completion
150                match call_completion_judge(
151                    self.llm_client.as_ref(),
152                    &ctx.user_input,
153                    &ctx.model_response,
154                    &ctx.all_user_inputs,
155                    self.config.judge_fail_open,
156                    self.config.judge_timeout_secs,
157                    self.config.recent_user_count,
158                )
159                .await
160                {
161                    Ok(judge) => {
162                        if judge.done {
163                            GuardDecision::Complete
164                        } else {
165                            GuardDecision::Continue {
166                                nudge: Some(format!(
167                                    "Your answer is incomplete: {}. Continue working on the task.",
168                                    judge.reason
169                                )),
170                            }
171                        }
172                    }
173                    Err(e) => {
174                        // Judge failed — behavior depends on judge_fail_open config
175                        tracing::warn!("completion judge failed: {}", e);
176                        if self.config.judge_fail_open {
177                            GuardDecision::Complete
178                        } else {
179                            GuardDecision::Continue {
180                                nudge: Some(
181                                    "Cannot verify task completion, please continue working."
182                                        .to_string(),
183                                ),
184                            }
185                        }
186                    }
187                }
188            } else {
189                // Short response without tools or judge disabled — nudge
190                GuardDecision::Continue {
191                    nudge: Some(self.config.short_response_nudge.clone()),
192                }
193            }
194        } else if ctx.run_has_tool_calls && self.config.use_llm_judge {
195            // Non-short response after tools — check skip threshold
196            if output_len >= self.config.judge_skip_threshold {
197                tracing::debug!(
198                    response_chars = output_len,
199                    threshold = self.config.judge_skip_threshold,
200                    "text-only response long enough, skipping judge"
201                );
202                return GuardDecision::Complete;
203            }
204
205            // Skip LLM judge for very large inputs — judge would be too slow
206            const INPUT_LEN_LIMIT: usize = 10_000;
207            if input_len > INPUT_LEN_LIMIT {
208                tracing::info!(
209                    input_chars = input_len,
210                    input_limit = INPUT_LEN_LIMIT,
211                    "skipping LLM judge — user input too large, trusting model"
212                );
213                return GuardDecision::Complete;
214            }
215
216            tracing::info!(
217                response_chars = output_len,
218                threshold = self.config.judge_skip_threshold,
219                "text-only response short, calling judge"
220            );
221            match call_completion_judge(
222                self.llm_client.as_ref(),
223                &ctx.user_input,
224                &ctx.model_response,
225                &ctx.all_user_inputs,
226                self.config.judge_fail_open,
227                self.config.judge_timeout_secs,
228                self.config.recent_user_count,
229            )
230            .await
231            {
232                Ok(judge) => {
233                    if judge.done {
234                        GuardDecision::Complete
235                    } else {
236                        GuardDecision::Continue {
237                            nudge: Some(format!(
238                                "Your answer is incomplete: {}. Continue working on the task.",
239                                judge.reason
240                            )),
241                        }
242                    }
243                }
244                Err(e) => {
245                    // Judge failed — behavior depends on judge_fail_open config
246                    tracing::warn!("completion judge failed: {}", e);
247                    if self.config.judge_fail_open {
248                        GuardDecision::Complete
249                    } else {
250                        GuardDecision::Continue {
251                            nudge: Some(
252                                "Cannot verify task completion, please continue working."
253                                    .to_string(),
254                            ),
255                        }
256                    }
257                }
258            }
259        } else {
260            GuardDecision::Complete
261        }
262    }
263}
264
265#[async_trait]
266impl ReactLoopGuard for DefaultGuard {
267    async fn on_turn(&self, ctx: &GuardCtx) -> GuardDecision {
268        if ctx.is_reasoning_only {
269            self.handle_reasoning_only(ctx).await
270        } else if ctx.is_empty_response {
271            self.handle_empty_response(ctx).await
272        } else if ctx.is_text_only {
273            self.handle_text_only(ctx).await
274        } else {
275            GuardDecision::Complete
276        }
277    }
278
279    async fn on_tool_call(&self, ctx: &GuardCtx) -> GuardDecision {
280        // Restore thinking when:
281        // 1. Thinking is currently disabled (by guard)
282        // 2. Original thinking was enabled (user wanted thinking)
283        // 3. Model calls a tool (showing it's working again)
284        if ctx.thinking_disabled && ctx.original_thinking_enabled {
285            tracing::info!(
286                session_id = ctx.session_id.id,
287                turn = ctx.turn_count,
288                "tool call detected while thinking disabled, restoring thinking"
289            );
290            return GuardDecision::RestoreThinking;
291        }
292
293        GuardDecision::Complete
294    }
295}