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 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 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 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 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 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 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 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 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 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 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}