1use agent_base::engine::react_loop_guard::{GuardAction, GuardCtx, ReactLoopGuard};
2use agent_base::llm::StreamClient;
3use agent_base::types::{ChatMessage, ResponseFormat};
4use async_trait::async_trait;
5use std::sync::Arc;
6use std::time::Duration;
7
8pub struct DefaultGuardConfig {
10 pub reasoning_only_max_strikes: usize,
12 pub empty_response_max_strikes: usize,
14 pub reasoning_only_nudge: String,
16 pub empty_response_nudge: String,
18 pub use_llm_judge: bool,
20 pub judge_timeout_secs: u64,
22 pub judge_skip_threshold: usize,
24 pub judge_fail_open: bool,
28 pub detect_short_response: bool,
32 pub short_response_min_input: usize,
34 pub short_response_max_output: usize,
36 pub short_response_nudge: String,
38}
39
40impl Default for DefaultGuardConfig {
41 fn default() -> Self {
42 Self {
43 reasoning_only_max_strikes: 3,
44 empty_response_max_strikes: 3,
45 reasoning_only_nudge: "You produced internal reasoning but no tool call \
46 and no final answer. Make a decision now: call a tool to make progress, \
47 or write your final answer as plain text."
48 .to_string(),
49 empty_response_nudge: "Your response was empty. Please provide a response \
50 with either a tool call or your final answer."
51 .to_string(),
52 use_llm_judge: true,
53 judge_timeout_secs: 10,
54 judge_skip_threshold: 256,
55 judge_fail_open: false, detect_short_response: true,
57 short_response_min_input: 128,
58 short_response_max_output: 64,
59 short_response_nudge: "Your response may be incomplete — \
60 you may need to continue."
61 .to_string(),
62 }
63 }
64}
65
66pub struct DefaultGuard {
70 config: DefaultGuardConfig,
71 llm_client: Option<Arc<dyn StreamClient>>,
72}
73
74impl DefaultGuard {
75 pub fn new(config: DefaultGuardConfig) -> Self {
76 Self {
77 config,
78 llm_client: None,
79 }
80 }
81
82 pub fn with_llm_client(config: DefaultGuardConfig, llm_client: Arc<dyn StreamClient>) -> Self {
84 Self {
85 config,
86 llm_client: Some(llm_client),
87 }
88 }
89
90 async fn call_completion_judge(
95 &self,
96 user_input: &str,
97 model_response: &str,
98 ) -> Result<JudgeResult, String> {
99 let Some(client) = &self.llm_client else {
100 if self.config.judge_fail_open {
102 return Ok(JudgeResult {
103 done: true,
104 reason: "no LLM client available for judge".to_string(),
105 });
106 } else {
107 return Err("no LLM client available for judge".to_string());
108 }
109 };
110
111 let system_prompt = "You are a task completion judge. \
112 Given the user's original question and the agent's response, \
113 determine if the agent has sufficiently answered the task. \
114 Reply with JSON: {\"done\": true/false, \"reason\": \"brief explanation\"}";
115
116 let user_prompt = format!(
117 "【User Question】\n{}\n\n【Agent Response】\n{}",
118 user_input, model_response
119 );
120
121 let messages = vec![
122 ChatMessage::system(system_prompt.to_string()),
123 ChatMessage::user(user_prompt),
124 ];
125
126 let timeout_duration = Duration::from_secs(self.config.judge_timeout_secs);
127
128 let result = tokio::time::timeout(timeout_duration, async {
129 let raw_response = client
130 .chat(&messages, &[], None, Some(&ResponseFormat::JsonObject))
131 .await
132 .map_err(|e| format!("LLM judge call failed: {}", e))?;
133
134 let result: JudgeResult = serde_json::from_str(&raw_response)
135 .map_err(|e| format!("Failed to parse judge response: {}", e))?;
136
137 Ok(result)
138 })
139 .await;
140
141 match result {
142 Ok(judge_result) => judge_result,
143 Err(_) => {
144 tracing::warn!(
146 timeout_secs = self.config.judge_timeout_secs,
147 fail_open = self.config.judge_fail_open,
148 "completion judge timeout or failure"
149 );
150 if self.config.judge_fail_open {
151 Ok(JudgeResult {
153 done: true,
154 reason: format!(
155 "judge timeout after {}s, trusting model",
156 self.config.judge_timeout_secs
157 ),
158 })
159 } else {
160 Err(format!(
162 "judge timeout after {}s, not trusting model",
163 self.config.judge_timeout_secs
164 ))
165 }
166 }
167 }
168 }
169}
170
171#[derive(serde::Deserialize, Debug)]
173struct JudgeResult {
174 done: bool,
175 reason: String,
176}
177
178#[async_trait]
179impl ReactLoopGuard for DefaultGuard {
180 async fn on_reasoning_only(&self, ctx: &GuardCtx) -> GuardAction {
181 let strikes = ctx.reasoning_only_strikes;
183
184 if strikes >= self.config.reasoning_only_max_strikes {
185 return GuardAction::Fail(
186 "model produced only reasoning across multiple turns".to_string(),
187 );
188 }
189
190 GuardAction::Continue(self.config.reasoning_only_nudge.clone())
191 }
192
193 async fn on_empty_response(&self, ctx: &GuardCtx) -> GuardAction {
194 let strikes = ctx.empty_response_strikes;
196
197 if strikes >= self.config.empty_response_max_strikes {
198 return GuardAction::Fail("model returned empty responses repeatedly".to_string());
199 }
200
201 GuardAction::Continue(self.config.empty_response_nudge.clone())
202 }
203
204 async fn on_text_only(&self, ctx: &GuardCtx) -> GuardAction {
205 let input_len = ctx.user_input.chars().count();
206 let output_len = ctx.model_response.chars().count();
207
208 let is_short_response = self.config.detect_short_response
211 && input_len > self.config.short_response_min_input
212 && output_len < self.config.short_response_max_output
213 && input_len > output_len;
214
215 if is_short_response {
216 tracing::info!(
217 input_chars = input_len,
218 output_chars = output_len,
219 min_input = self.config.short_response_min_input,
220 max_output = self.config.short_response_max_output,
221 run_has_tool_calls = ctx.run_has_tool_calls,
222 "short response detected in text-only branch"
223 );
224
225 if ctx.run_has_tool_calls && self.config.use_llm_judge {
226 const INPUT_LEN_LIMIT: usize = 10_000;
228 if input_len > INPUT_LEN_LIMIT {
229 tracing::info!(
230 input_chars = input_len,
231 input_limit = INPUT_LEN_LIMIT,
232 "skipping LLM judge — user input too large, trusting model"
233 );
234 return GuardAction::Done;
235 }
236 match self
238 .call_completion_judge(&ctx.user_input, &ctx.model_response)
239 .await
240 {
241 Ok(judge) => {
242 if judge.done {
243 GuardAction::Done
244 } else {
245 GuardAction::Continue(format!(
246 "Your answer is incomplete: {}. Continue working on the task.",
247 judge.reason
248 ))
249 }
250 }
251 Err(e) => {
252 tracing::warn!("completion judge failed: {}", e);
254 if self.config.judge_fail_open {
255 GuardAction::Done
256 } else {
257 GuardAction::Continue(
258 "Cannot verify task completion, please continue working."
259 .to_string(),
260 )
261 }
262 }
263 }
264 } else {
265 GuardAction::Continue(self.config.short_response_nudge.clone())
267 }
268 } else if ctx.run_has_tool_calls && self.config.use_llm_judge {
269 if output_len >= self.config.judge_skip_threshold {
271 tracing::debug!(
272 response_chars = output_len,
273 threshold = self.config.judge_skip_threshold,
274 "text-only response long enough, skipping judge"
275 );
276 return GuardAction::Done;
277 }
278
279 const INPUT_LEN_LIMIT: usize = 10_000;
281 if input_len > INPUT_LEN_LIMIT {
282 tracing::info!(
283 input_chars = input_len,
284 input_limit = INPUT_LEN_LIMIT,
285 "skipping LLM judge — user input too large, trusting model"
286 );
287 return GuardAction::Done;
288 }
289
290 tracing::info!(
291 response_chars = output_len,
292 threshold = self.config.judge_skip_threshold,
293 "text-only response short, calling judge"
294 );
295 match self
296 .call_completion_judge(&ctx.user_input, &ctx.model_response)
297 .await
298 {
299 Ok(judge) => {
300 if judge.done {
301 GuardAction::Done
302 } else {
303 GuardAction::Continue(format!(
304 "Your answer is incomplete: {}. Continue working on the task.",
305 judge.reason
306 ))
307 }
308 }
309 Err(e) => {
310 tracing::warn!("completion judge failed: {}", e);
312 if self.config.judge_fail_open {
313 GuardAction::Done
314 } else {
315 GuardAction::Continue(
316 "Cannot verify task completion, please continue working.".to_string(),
317 )
318 }
319 }
320 }
321 } else {
322 GuardAction::Done
323 }
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use agent_base::llm::{LlmCapabilities, StreamChunk};
331 use agent_base::types::{AgentResult, FinishReason, SessionId};
332 use futures_core::Stream;
333 use serde_json::Value;
334 use std::pin::Pin;
335 use std::task::{Context, Poll};
336
337 fn make_ctx(
338 reasoning_only_strikes: usize,
339 empty_response_strikes: usize,
340 run_has_tool_calls: bool,
341 ) -> GuardCtx {
342 GuardCtx {
343 session_id: SessionId {
344 id: 1,
345 external_id: None,
346 },
347 turn_count: 1,
348 user_input: "test".to_string(),
349 model_response: "response".to_string(),
350 finish_reason: FinishReason::Stop,
351 available_tools: vec![],
352 reasoning_only_strikes,
353 empty_response_strikes,
354 run_has_tool_calls,
355 }
356 }
357
358 fn make_ctx_with_text(
360 user_input: &str,
361 model_response: &str,
362 run_has_tool_calls: bool,
363 ) -> GuardCtx {
364 GuardCtx {
365 session_id: SessionId {
366 id: 1,
367 external_id: None,
368 },
369 turn_count: 2,
370 user_input: user_input.to_string(),
371 model_response: model_response.to_string(),
372 finish_reason: FinishReason::Stop,
373 available_tools: vec!["echo".to_string()],
374 reasoning_only_strikes: 0,
375 empty_response_strikes: 0,
376 run_has_tool_calls,
377 }
378 }
379
380 struct MockJudgeClient {
384 response: String,
385 }
386
387 impl MockJudgeClient {
388 fn new(response: Value) -> Self {
389 Self {
390 response: response.to_string(),
391 }
392 }
393 }
394
395 #[async_trait]
396 impl StreamClient for MockJudgeClient {
397 async fn stream(
398 &self,
399 _messages: &[ChatMessage],
400 _tools: &[Value],
401 _reasoning: Option<&agent_base::ReasoningConfig>,
402 _response_format: Option<&ResponseFormat>,
403 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
404 let chunks = vec![
405 Ok(StreamChunk::Text(self.response.clone())),
406 Ok(StreamChunk::Stop {
407 finish_reason: Some("stop".to_string()),
408 }),
409 ];
410 Ok(Box::pin(futures_util::stream::iter(chunks)))
411 }
412
413 fn capabilities(&self) -> LlmCapabilities {
414 LlmCapabilities::default()
415 }
416 }
417
418 struct MockTimeoutClient;
420
421 #[async_trait]
422 impl StreamClient for MockTimeoutClient {
423 async fn stream(
424 &self,
425 _messages: &[ChatMessage],
426 _tools: &[Value],
427 _reasoning: Option<&agent_base::ReasoningConfig>,
428 _response_format: Option<&ResponseFormat>,
429 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
430 struct HangingStream;
432 impl Stream for HangingStream {
433 type Item = AgentResult<StreamChunk>;
434 fn poll_next(
435 self: Pin<&mut Self>,
436 _cx: &mut Context<'_>,
437 ) -> Poll<Option<Self::Item>> {
438 Poll::Pending }
440 }
441 Ok(Box::pin(HangingStream))
442 }
443
444 fn capabilities(&self) -> LlmCapabilities {
445 LlmCapabilities::default()
446 }
447 }
448
449 #[tokio::test]
450 async fn test_reasoning_only_below_threshold() {
451 let guard = DefaultGuard::new(DefaultGuardConfig::default());
452 let ctx = make_ctx(1, 0, false);
453
454 let action = guard.on_reasoning_only(&ctx).await;
455 assert!(matches!(action, GuardAction::Continue(_)));
456 }
457
458 #[tokio::test]
459 async fn test_reasoning_only_at_threshold() {
460 let guard = DefaultGuard::new(DefaultGuardConfig::default());
461 let ctx = make_ctx(3, 0, false);
462
463 let action = guard.on_reasoning_only(&ctx).await;
464 assert!(matches!(action, GuardAction::Fail(_)));
465 }
466
467 #[tokio::test]
468 async fn test_empty_response_below_threshold() {
469 let guard = DefaultGuard::new(DefaultGuardConfig::default());
470 let ctx = make_ctx(0, 1, false);
471
472 let action = guard.on_empty_response(&ctx).await;
473 assert!(matches!(action, GuardAction::Continue(_)));
474 }
475
476 #[tokio::test]
477 async fn test_empty_response_at_threshold() {
478 let guard = DefaultGuard::new(DefaultGuardConfig::default());
479 let ctx = make_ctx(0, 3, false);
480
481 let action = guard.on_empty_response(&ctx).await;
482 assert!(matches!(action, GuardAction::Fail(_)));
483 }
484
485 #[tokio::test]
486 async fn test_text_only_without_tool_calls() {
487 let guard = DefaultGuard::new(DefaultGuardConfig::default());
488 let ctx = make_ctx(0, 0, false);
489
490 let action = guard.on_text_only(&ctx).await;
491 assert!(matches!(action, GuardAction::Done));
492 }
493
494 #[tokio::test]
495 async fn test_text_only_with_tool_calls_no_llm_client() {
496 let config = DefaultGuardConfig {
497 use_llm_judge: true,
498 ..DefaultGuardConfig::default()
499 };
500 let guard = DefaultGuard::new(config);
501 let ctx = make_ctx(0, 0, true);
502
503 let action = guard.on_text_only(&ctx).await;
505 assert!(matches!(action, GuardAction::Continue(_)));
506 }
507
508 #[tokio::test]
509 async fn test_text_only_with_tool_calls_no_llm_client_fail_open() {
510 let config = DefaultGuardConfig {
511 use_llm_judge: true,
512 judge_fail_open: true,
513 ..DefaultGuardConfig::default()
514 };
515 let guard = DefaultGuard::new(config);
516 let ctx = make_ctx(0, 0, true);
517
518 let action = guard.on_text_only(&ctx).await;
520 assert!(matches!(action, GuardAction::Done));
521 }
522
523 #[tokio::test]
524 async fn test_text_only_with_tool_calls_judge_disabled() {
525 let config = DefaultGuardConfig {
526 use_llm_judge: false,
527 ..DefaultGuardConfig::default()
528 };
529 let guard = DefaultGuard::new(config);
530 let ctx = make_ctx(0, 0, true);
531
532 let action = guard.on_text_only(&ctx).await;
534 assert!(matches!(action, GuardAction::Done));
535 }
536
537 #[tokio::test]
538 async fn test_custom_config() {
539 let config = DefaultGuardConfig {
540 reasoning_only_max_strikes: 5,
541 empty_response_max_strikes: 2,
542 ..DefaultGuardConfig::default()
543 };
544 let guard = DefaultGuard::new(config);
545
546 let ctx = make_ctx(4, 0, false);
548 let action = guard.on_reasoning_only(&ctx).await;
549 assert!(matches!(action, GuardAction::Continue(_)));
550
551 let ctx = make_ctx(0, 2, false);
553 let action = guard.on_empty_response(&ctx).await;
554 assert!(matches!(action, GuardAction::Fail(_)));
555 }
556
557 #[tokio::test]
560 async fn test_text_only_judge_says_done() {
561 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
562 "done": true,
563 "reason": "task is complete"
564 })));
565 let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
566 let ctx = make_ctx_with_text(
567 "what is 2+2?",
568 "The answer is 4.",
569 true, );
571
572 let action = guard.on_text_only(&ctx).await;
573 assert!(
574 matches!(action, GuardAction::Done),
575 "judge says done → Done, got: {:?}",
576 action
577 );
578 }
579
580 #[tokio::test]
581 async fn test_text_only_judge_says_not_done() {
582 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
583 "done": false,
584 "reason": "only answered part of the question"
585 })));
586 let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
587 let ctx = make_ctx_with_text(
588 "list all files and explain each",
589 "Here are the files:", true,
591 );
592
593 let action = guard.on_text_only(&ctx).await;
594 match &action {
595 GuardAction::Continue(msg) => {
596 assert!(
597 msg.contains("incomplete"),
598 "nudge should mention incomplete: {}",
599 msg
600 );
601 assert!(
602 msg.contains("only answered part"),
603 "nudge should include judge reason: {}",
604 msg
605 );
606 }
607 other => panic!("expected Continue, got: {:?}", other),
608 }
609 }
610
611 #[tokio::test]
612 async fn test_text_only_short_response_detected_no_tools() {
613 let long_input = "a".repeat(200);
615 let short_output = "done";
616 let guard = DefaultGuard::new(DefaultGuardConfig::default());
617 let ctx = make_ctx_with_text(&long_input, short_output, false);
618
619 let action = guard.on_text_only(&ctx).await;
620 match &action {
621 GuardAction::Continue(msg) => {
622 assert!(msg.contains("incomplete"), "short response nudge: {}", msg);
623 }
624 other => panic!("expected Continue for short response, got: {:?}", other),
625 }
626 }
627
628 #[tokio::test]
629 async fn test_text_only_short_response_with_judge_done() {
630 let long_input = "a".repeat(200);
632 let short_output = "42";
633 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
634 "done": true,
635 "reason": "answer is correct"
636 })));
637 let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
638 let ctx = make_ctx_with_text(&long_input, short_output, true);
639
640 let action = guard.on_text_only(&ctx).await;
641 assert!(
642 matches!(action, GuardAction::Done),
643 "short response + judge done → Done, got: {:?}",
644 action
645 );
646 }
647
648 #[tokio::test]
649 async fn test_text_only_skip_threshold() {
650 let long_output = "x".repeat(300);
652 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
653 "done": false,
654 "reason": "would be incomplete but skipped"
655 })));
656 let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
657 let ctx = make_ctx_with_text("query", &long_output, true);
658
659 let action = guard.on_text_only(&ctx).await;
660 assert!(
661 matches!(action, GuardAction::Done),
662 "response >= skip_threshold → Done without calling judge, got: {:?}",
663 action
664 );
665 }
666
667 #[tokio::test]
668 async fn test_text_only_judge_timeout_fail_open() {
669 let judge_client = Arc::new(MockTimeoutClient);
670 let config = DefaultGuardConfig {
671 judge_fail_open: true,
672 judge_timeout_secs: 1, ..DefaultGuardConfig::default()
674 };
675 let guard = DefaultGuard::with_llm_client(config, judge_client);
676 let ctx = make_ctx_with_text("query", "short answer", true);
677
678 let action = guard.on_text_only(&ctx).await;
679 assert!(
680 matches!(action, GuardAction::Done),
681 "timeout + fail_open → Done (trust model), got: {:?}",
682 action
683 );
684 }
685
686 #[tokio::test]
687 async fn test_text_only_judge_timeout_fail_closed() {
688 let judge_client = Arc::new(MockTimeoutClient);
689 let config = DefaultGuardConfig {
690 judge_fail_open: false,
691 judge_timeout_secs: 1, ..DefaultGuardConfig::default()
693 };
694 let guard = DefaultGuard::with_llm_client(config, judge_client);
695 let ctx = make_ctx_with_text("query", "short answer", true);
696
697 let action = guard.on_text_only(&ctx).await;
698 match &action {
699 GuardAction::Continue(msg) => {
700 assert!(
701 msg.contains("Cannot verify"),
702 "fail_closed timeout → Continue with verify message: {}",
703 msg
704 );
705 }
706 other => panic!(
707 "expected Continue for timeout + fail_closed, got: {:?}",
708 other
709 ),
710 }
711 }
712
713 #[tokio::test]
714 async fn test_text_only_skip_judge_when_input_too_large() {
715 let long_input = "a".repeat(10_001);
717 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
718 "done": false,
719 "reason": "would be incomplete but skipped"
720 })));
721 let guard = DefaultGuard::with_llm_client(DefaultGuardConfig::default(), judge_client);
722 let ctx = make_ctx_with_text(&long_input, "short answer", true);
723
724 let action = guard.on_text_only(&ctx).await;
725 assert!(
726 matches!(action, GuardAction::Done),
727 "input > 10k → skip judge → Done, got: {:?}",
728 action
729 );
730 }
731}