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.".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    /// Build a GuardCtx with custom input/response text (for length-sensitive tests).
359    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    // ── Mock LLM clients for judge testing ──
381
382    /// Mock StreamClient that returns a fixed JSON response (for judge calls).
383    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    /// Mock StreamClient that never resolves (for timeout testing).
419    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            // Return a stream whose first item never resolves.
431            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 // never wakes
439                }
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        // No LLM client, judge_fail_open=false (default), should force continue
504        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        // No LLM client, judge_fail_open=true, should trust model (Done)
519        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        // LLM judge disabled, should Done
533        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        // reasoning-only: 4 < 5, should continue
547        let ctx = make_ctx(4, 0, false);
548        let action = guard.on_reasoning_only(&ctx).await;
549        assert!(matches!(action, GuardAction::Continue(_)));
550
551        // empty response: 2 >= 2, should fail
552        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    // ── Branch 4: text-only after tools — LLM judge tests ──
558
559    #[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, // run_has_tool_calls
570        );
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:", // incomplete
590            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        // Long input (>128 chars) + short output (<64 chars) + no tool calls → nudge
614        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        // Short response + tools + judge says done → Done
631        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        // Output >= 256 chars → skip judge entirely → Done
651        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, // 1 second timeout
673            ..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, // 1 second timeout
692            ..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        // user_input > 10k chars → skip judge, trust model (Done)
716        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}