Skip to main content

agent_works/guard/
default.rs

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
8/// Default guard configuration
9pub struct DefaultGuardConfig {
10    /// Maximum retries for reasoning-only responses
11    pub reasoning_only_max_strikes: usize,
12    /// Maximum retries for empty responses
13    pub empty_response_max_strikes: usize,
14    /// Nudge message for reasoning-only responses
15    pub reasoning_only_nudge: String,
16    /// Nudge message for empty responses
17    pub empty_response_nudge: String,
18    /// Whether to use LLM judge for text-only after tools
19    pub use_llm_judge: bool,
20    /// Timeout in seconds for the LLM judge call
21    pub judge_timeout_secs: u64,
22    /// Skip judge if response is longer than this (likely complete)
23    pub judge_skip_threshold: usize,
24    /// Whether to trust LLM when judge fails or times out.
25    /// - true: fail-open (trust the model, end loop)
26    /// - false: fail-closed (don't trust, force continue)
27    pub judge_fail_open: bool,
28    /// Enable short-response detection (merged from CompletionGateMiddleware).
29    /// When the user input is long but the model response is very short,
30    /// treat it as potentially incomplete and nudge/judge accordingly.
31    pub detect_short_response: bool,
32    /// Minimum user input character count to trigger short-response detection.
33    pub short_response_min_input: usize,
34    /// Maximum LLM output character count to be considered a short response.
35    pub short_response_max_output: usize,
36    /// Nudge message for short responses
37    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, // Default: don't trust LLM on judge failure
56            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
66/// Default guard implementation
67///
68/// Does not manage its own state; uses RunState information from GuardCtx.
69pub 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    /// Create a new DefaultGuard with LLM client for judge functionality
83    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    /// Call LLM judge to determine if the task is complete
91    ///
92    /// Used when the model returns text-only after having called tools —
93    /// this is suspicious and needs verification.
94    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            // No LLM client available — use configured behavior
101            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                // Timeout or failure — use configured behavior
145                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                    // Fail-open: trust the model
152                    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                    // Fail-closed: don't trust the model
161                    Err(format!(
162                        "judge timeout after {}s, not trusting model",
163                        self.config.judge_timeout_secs
164                    ))
165                }
166            }
167        }
168    }
169}
170
171/// LLM judge result
172#[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        // Use RunState information from GuardCtx
182        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        // Use RunState information from GuardCtx
195        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        // Short-response detection: user asked a substantial question but the
209        // model gave a very short answer — likely incomplete.
210        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                // Skip LLM judge for very large inputs — judge would be too slow
227                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                // Short response after tools — call judge to verify completion
237                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                        // Judge failed — behavior depends on judge_fail_open config
253                        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                // Short response without tools or judge disabled — nudge
266                GuardAction::Continue(self.config.short_response_nudge.clone())
267            }
268        } else if ctx.run_has_tool_calls && self.config.use_llm_judge {
269            // Non-short response after tools — check skip threshold
270            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            // Skip LLM judge for very large inputs — judge would be too slow
280            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                    // Judge failed — behavior depends on judge_fail_open config
311                    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    /// Build a GuardCtx with custom input/response text (for length-sensitive tests).
360    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    // ── Mock LLM clients for judge testing ──
382
383    /// Mock StreamClient that returns a fixed JSON response (for judge calls).
384    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    /// Mock StreamClient that never resolves (for timeout testing).
420    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            // Return a stream whose first item never resolves.
432            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 // never wakes
440                }
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        // No LLM client, judge_fail_open=false (default), should force continue
505        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        // No LLM client, judge_fail_open=true, should trust model (Done)
520        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        // LLM judge disabled, should Done
534        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        // reasoning-only: 4 < 5, should continue
548        let ctx = make_ctx(4, 0, false);
549        let action = guard.on_reasoning_only(&ctx).await;
550        assert!(matches!(action, GuardAction::Continue(_)));
551
552        // empty response: 2 >= 2, should fail
553        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    // ── Branch 4: text-only after tools — LLM judge tests ──
559
560    #[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, // run_has_tool_calls
574        );
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:", // incomplete
597            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        // Long input (>128 chars) + short output (<64 chars) + no tool calls → nudge
621        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        // Short response + tools + judge says done → Done
642        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        // Output >= 256 chars → skip judge entirely → Done
665        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, // 1 second timeout
690            ..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, // 1 second timeout
709            ..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        // user_input > 10k chars → skip judge, trust model (Done)
733        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}