agent_works/guard/
default.rs1use 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
9pub 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 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 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 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 if strikes >= self.config.reasoning_only_max_strikes {
54 if ctx.thinking_disabled {
56 return GuardDecision::Fail {
58 error: "model produced only reasoning even after thinking was disabled"
59 .to_string(),
60 };
61 }
62
63 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 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 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 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 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 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 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 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 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 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}