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."
317 .to_string(),
318 )
319 }
320 }
321 }
322 } else {
323 GuardAction::Done
324 }
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use agent_base::llm::{LlmCapabilities, StreamChunk};
332 use agent_base::types::{AgentResult, FinishReason, SessionId};
333 use futures_core::Stream;
334 use serde_json::Value;
335 use std::pin::Pin;
336 use std::task::{Context, Poll};
337
338 fn make_ctx(
339 reasoning_only_strikes: usize,
340 empty_response_strikes: usize,
341 run_has_tool_calls: bool,
342 ) -> GuardCtx {
343 GuardCtx {
344 session_id: SessionId {
345 id: 1,
346 external_id: None,
347 },
348 turn_count: 1,
349 user_input: "test".to_string(),
350 model_response: "response".to_string(),
351 finish_reason: FinishReason::Stop,
352 available_tools: vec![],
353 reasoning_only_strikes,
354 empty_response_strikes,
355 run_has_tool_calls,
356 }
357 }
358
359 fn make_ctx_with_text(
361 user_input: &str,
362 model_response: &str,
363 run_has_tool_calls: bool,
364 ) -> GuardCtx {
365 GuardCtx {
366 session_id: SessionId {
367 id: 1,
368 external_id: None,
369 },
370 turn_count: 2,
371 user_input: user_input.to_string(),
372 model_response: model_response.to_string(),
373 finish_reason: FinishReason::Stop,
374 available_tools: vec!["echo".to_string()],
375 reasoning_only_strikes: 0,
376 empty_response_strikes: 0,
377 run_has_tool_calls,
378 }
379 }
380
381 struct MockJudgeClient {
385 response: String,
386 }
387
388 impl MockJudgeClient {
389 fn new(response: Value) -> Self {
390 Self {
391 response: response.to_string(),
392 }
393 }
394 }
395
396 #[async_trait]
397 impl StreamClient for MockJudgeClient {
398 async fn stream(
399 &self,
400 _messages: &[ChatMessage],
401 _tools: &[Value],
402 _reasoning: Option<&agent_base::ReasoningConfig>,
403 _response_format: Option<&ResponseFormat>,
404 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
405 let chunks = vec![
406 Ok(StreamChunk::Text(self.response.clone())),
407 Ok(StreamChunk::Stop {
408 finish_reason: Some("stop".to_string()),
409 }),
410 ];
411 Ok(Box::pin(futures_util::stream::iter(chunks)))
412 }
413
414 fn capabilities(&self) -> LlmCapabilities {
415 LlmCapabilities::default()
416 }
417 }
418
419 struct MockTimeoutClient;
421
422 #[async_trait]
423 impl StreamClient for MockTimeoutClient {
424 async fn stream(
425 &self,
426 _messages: &[ChatMessage],
427 _tools: &[Value],
428 _reasoning: Option<&agent_base::ReasoningConfig>,
429 _response_format: Option<&ResponseFormat>,
430 ) -> AgentResult<Pin<Box<dyn Stream<Item = AgentResult<StreamChunk>> + Send>>> {
431 struct HangingStream;
433 impl Stream for HangingStream {
434 type Item = AgentResult<StreamChunk>;
435 fn poll_next(
436 self: Pin<&mut Self>,
437 _cx: &mut Context<'_>,
438 ) -> Poll<Option<Self::Item>> {
439 Poll::Pending }
441 }
442 Ok(Box::pin(HangingStream))
443 }
444
445 fn capabilities(&self) -> LlmCapabilities {
446 LlmCapabilities::default()
447 }
448 }
449
450 #[tokio::test]
451 async fn test_reasoning_only_below_threshold() {
452 let guard = DefaultGuard::new(DefaultGuardConfig::default());
453 let ctx = make_ctx(1, 0, false);
454
455 let action = guard.on_reasoning_only(&ctx).await;
456 assert!(matches!(action, GuardAction::Continue(_)));
457 }
458
459 #[tokio::test]
460 async fn test_reasoning_only_at_threshold() {
461 let guard = DefaultGuard::new(DefaultGuardConfig::default());
462 let ctx = make_ctx(3, 0, false);
463
464 let action = guard.on_reasoning_only(&ctx).await;
465 assert!(matches!(action, GuardAction::Fail(_)));
466 }
467
468 #[tokio::test]
469 async fn test_empty_response_below_threshold() {
470 let guard = DefaultGuard::new(DefaultGuardConfig::default());
471 let ctx = make_ctx(0, 1, false);
472
473 let action = guard.on_empty_response(&ctx).await;
474 assert!(matches!(action, GuardAction::Continue(_)));
475 }
476
477 #[tokio::test]
478 async fn test_empty_response_at_threshold() {
479 let guard = DefaultGuard::new(DefaultGuardConfig::default());
480 let ctx = make_ctx(0, 3, false);
481
482 let action = guard.on_empty_response(&ctx).await;
483 assert!(matches!(action, GuardAction::Fail(_)));
484 }
485
486 #[tokio::test]
487 async fn test_text_only_without_tool_calls() {
488 let guard = DefaultGuard::new(DefaultGuardConfig::default());
489 let ctx = make_ctx(0, 0, false);
490
491 let action = guard.on_text_only(&ctx).await;
492 assert!(matches!(action, GuardAction::Done));
493 }
494
495 #[tokio::test]
496 async fn test_text_only_with_tool_calls_no_llm_client() {
497 let config = DefaultGuardConfig {
498 use_llm_judge: true,
499 ..DefaultGuardConfig::default()
500 };
501 let guard = DefaultGuard::new(config);
502 let ctx = make_ctx(0, 0, true);
503
504 let action = guard.on_text_only(&ctx).await;
506 assert!(matches!(action, GuardAction::Continue(_)));
507 }
508
509 #[tokio::test]
510 async fn test_text_only_with_tool_calls_no_llm_client_fail_open() {
511 let config = DefaultGuardConfig {
512 use_llm_judge: true,
513 judge_fail_open: true,
514 ..DefaultGuardConfig::default()
515 };
516 let guard = DefaultGuard::new(config);
517 let ctx = make_ctx(0, 0, true);
518
519 let action = guard.on_text_only(&ctx).await;
521 assert!(matches!(action, GuardAction::Done));
522 }
523
524 #[tokio::test]
525 async fn test_text_only_with_tool_calls_judge_disabled() {
526 let config = DefaultGuardConfig {
527 use_llm_judge: false,
528 ..DefaultGuardConfig::default()
529 };
530 let guard = DefaultGuard::new(config);
531 let ctx = make_ctx(0, 0, true);
532
533 let action = guard.on_text_only(&ctx).await;
535 assert!(matches!(action, GuardAction::Done));
536 }
537
538 #[tokio::test]
539 async fn test_custom_config() {
540 let config = DefaultGuardConfig {
541 reasoning_only_max_strikes: 5,
542 empty_response_max_strikes: 2,
543 ..DefaultGuardConfig::default()
544 };
545 let guard = DefaultGuard::new(config);
546
547 let ctx = make_ctx(4, 0, false);
549 let action = guard.on_reasoning_only(&ctx).await;
550 assert!(matches!(action, GuardAction::Continue(_)));
551
552 let ctx = make_ctx(0, 2, false);
554 let action = guard.on_empty_response(&ctx).await;
555 assert!(matches!(action, GuardAction::Fail(_)));
556 }
557
558 #[tokio::test]
561 async fn test_text_only_judge_says_done() {
562 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
563 "done": true,
564 "reason": "task is complete"
565 })));
566 let guard = DefaultGuard::with_llm_client(
567 DefaultGuardConfig::default(),
568 judge_client,
569 );
570 let ctx = make_ctx_with_text(
571 "what is 2+2?",
572 "The answer is 4.",
573 true, );
575
576 let action = guard.on_text_only(&ctx).await;
577 assert!(
578 matches!(action, GuardAction::Done),
579 "judge says done → Done, got: {:?}",
580 action
581 );
582 }
583
584 #[tokio::test]
585 async fn test_text_only_judge_says_not_done() {
586 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
587 "done": false,
588 "reason": "only answered part of the question"
589 })));
590 let guard = DefaultGuard::with_llm_client(
591 DefaultGuardConfig::default(),
592 judge_client,
593 );
594 let ctx = make_ctx_with_text(
595 "list all files and explain each",
596 "Here are the files:", true,
598 );
599
600 let action = guard.on_text_only(&ctx).await;
601 match &action {
602 GuardAction::Continue(msg) => {
603 assert!(
604 msg.contains("incomplete"),
605 "nudge should mention incomplete: {}",
606 msg
607 );
608 assert!(
609 msg.contains("only answered part"),
610 "nudge should include judge reason: {}",
611 msg
612 );
613 }
614 other => panic!("expected Continue, got: {:?}", other),
615 }
616 }
617
618 #[tokio::test]
619 async fn test_text_only_short_response_detected_no_tools() {
620 let long_input = "a".repeat(200);
622 let short_output = "done";
623 let guard = DefaultGuard::new(DefaultGuardConfig::default());
624 let ctx = make_ctx_with_text(&long_input, short_output, false);
625
626 let action = guard.on_text_only(&ctx).await;
627 match &action {
628 GuardAction::Continue(msg) => {
629 assert!(
630 msg.contains("incomplete"),
631 "short response nudge: {}",
632 msg
633 );
634 }
635 other => panic!("expected Continue for short response, got: {:?}", other),
636 }
637 }
638
639 #[tokio::test]
640 async fn test_text_only_short_response_with_judge_done() {
641 let long_input = "a".repeat(200);
643 let short_output = "42";
644 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
645 "done": true,
646 "reason": "answer is correct"
647 })));
648 let guard = DefaultGuard::with_llm_client(
649 DefaultGuardConfig::default(),
650 judge_client,
651 );
652 let ctx = make_ctx_with_text(&long_input, short_output, true);
653
654 let action = guard.on_text_only(&ctx).await;
655 assert!(
656 matches!(action, GuardAction::Done),
657 "short response + judge done → Done, got: {:?}",
658 action
659 );
660 }
661
662 #[tokio::test]
663 async fn test_text_only_skip_threshold() {
664 let long_output = "x".repeat(300);
666 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
667 "done": false,
668 "reason": "would be incomplete but skipped"
669 })));
670 let guard = DefaultGuard::with_llm_client(
671 DefaultGuardConfig::default(),
672 judge_client,
673 );
674 let ctx = make_ctx_with_text("query", &long_output, true);
675
676 let action = guard.on_text_only(&ctx).await;
677 assert!(
678 matches!(action, GuardAction::Done),
679 "response >= skip_threshold → Done without calling judge, got: {:?}",
680 action
681 );
682 }
683
684 #[tokio::test]
685 async fn test_text_only_judge_timeout_fail_open() {
686 let judge_client = Arc::new(MockTimeoutClient);
687 let config = DefaultGuardConfig {
688 judge_fail_open: true,
689 judge_timeout_secs: 1, ..DefaultGuardConfig::default()
691 };
692 let guard = DefaultGuard::with_llm_client(config, judge_client);
693 let ctx = make_ctx_with_text("query", "short answer", true);
694
695 let action = guard.on_text_only(&ctx).await;
696 assert!(
697 matches!(action, GuardAction::Done),
698 "timeout + fail_open → Done (trust model), got: {:?}",
699 action
700 );
701 }
702
703 #[tokio::test]
704 async fn test_text_only_judge_timeout_fail_closed() {
705 let judge_client = Arc::new(MockTimeoutClient);
706 let config = DefaultGuardConfig {
707 judge_fail_open: false,
708 judge_timeout_secs: 1, ..DefaultGuardConfig::default()
710 };
711 let guard = DefaultGuard::with_llm_client(config, judge_client);
712 let ctx = make_ctx_with_text("query", "short answer", true);
713
714 let action = guard.on_text_only(&ctx).await;
715 match &action {
716 GuardAction::Continue(msg) => {
717 assert!(
718 msg.contains("Cannot verify"),
719 "fail_closed timeout → Continue with verify message: {}",
720 msg
721 );
722 }
723 other => panic!(
724 "expected Continue for timeout + fail_closed, got: {:?}",
725 other
726 ),
727 }
728 }
729
730 #[tokio::test]
731 async fn test_text_only_skip_judge_when_input_too_large() {
732 let long_input = "a".repeat(10_001);
734 let judge_client = Arc::new(MockJudgeClient::new(serde_json::json!({
735 "done": false,
736 "reason": "would be incomplete but skipped"
737 })));
738 let guard = DefaultGuard::with_llm_client(
739 DefaultGuardConfig::default(),
740 judge_client,
741 );
742 let ctx = make_ctx_with_text(&long_input, "short answer", true);
743
744 let action = guard.on_text_only(&ctx).await;
745 assert!(
746 matches!(action, GuardAction::Done),
747 "input > 10k → skip judge → Done, got: {:?}",
748 action
749 );
750 }
751}