diff --git a/intercept/intercept.go b/intercept/intercept.go index 196e690..796b9fb 100644 --- a/intercept/intercept.go +++ b/intercept/intercept.go @@ -305,6 +305,8 @@ const ( settingJudgeProfileID = "llm_judge_profile_id" settingJudgePrompt = "llm_judge_prompt" settingJudgeTimeoutSecs = "llm_judge_timeout_seconds" + settingJudgeRetryCount = "llm_judge_retry_count" + settingJudgeRetryIntervalMS = "llm_judge_retry_interval_ms" settingJudgeFailAction = "llm_judge_fail_action" settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds" settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action" @@ -327,7 +329,9 @@ type JudgeConfig struct { Enabled bool `json:"enabled"` ProfileID int64 `json:"profile_id"` // 0 = follow active/default Prompt string `json:"prompt"` - TimeoutSeconds int `json:"timeout_seconds"` + TimeoutSeconds int `json:"timeout_seconds"` // per attempt + RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled + RetryIntervalMS int `json:"retry_interval_ms"` FailAction string `json:"fail_action"` // allow|ask|deny AskTimeoutSeconds int `json:"ask_timeout_seconds"` AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny @@ -342,6 +346,8 @@ func (i *Interceptor) judgeConfig() JudgeConfig { Enabled: i.db.GetBool(settingJudgeEnabled, false), ProfileID: int64(i.getSettingInt(settingJudgeProfileID, 0)), TimeoutSeconds: i.getSettingInt(settingJudgeTimeoutSecs, defaultJudgeTimeoutSecs), + RetryCount: min(max(i.getSettingInt(settingJudgeRetryCount, 0), 0), MaxJudgeRetryCount), + RetryIntervalMS: min(max(i.getSettingInt(settingJudgeRetryIntervalMS, defaultJudgeRetryIntervalMS), 0), MaxJudgeRetryIntervalMS), FailAction: i.getSettingChoice(settingJudgeFailAction, defaultJudgeFailAction, "allow", "ask", "deny"), AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs), AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"), @@ -395,6 +401,9 @@ func (i *Interceptor) GetJudgeConfig() JudgeConfig { return i.judgeConfig() } // SetJudgeConfig persists the judge configuration. An empty Prompt clears the // override (the built-in template is used again). func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error { + if err := c.ValidateRetry(); err != nil { + return err + } if err := i.db.SetBool(settingJudgeEnabled, c.Enabled); err != nil { return err } @@ -413,6 +422,12 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error { if err := i.db.SetSetting(settingJudgeTimeoutSecs, strconv.Itoa(c.TimeoutSeconds)); err != nil { return err } + if err := i.db.SetSetting(settingJudgeRetryCount, strconv.Itoa(c.RetryCount)); err != nil { + return err + } + if err := i.db.SetSetting(settingJudgeRetryIntervalMS, strconv.Itoa(c.RetryIntervalMS)); err != nil { + return err + } if err := i.db.SetSetting(settingJudgeFailAction, c.FailAction); err != nil { return err } @@ -432,7 +447,8 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error { // returns (Decision, true) when the judge produced a terminal verdict, or // (Decision{}, false) when the fallback is disabled or not wired (caller then // keeps the current behavior: allow). On model error or an unparseable reply it -// falls back to the configured FailAction. Ask verdicts carry the human-approval +// retries up to RetryCount times, then falls back to the configured FailAction. +// Ask verdicts carry the human-approval // timeout so the existing HandleAsk consumes them unchanged. func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.RawMessage) (Decision, bool) { cfg := i.judgeConfig() @@ -444,13 +460,13 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw } cctx := ctx + cancel := func() {} if cfg.TimeoutSeconds > 0 { - var cancel context.CancelFunc cctx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second) - defer cancel() } input, contextErr := BuildReviewInput(cctx, tool, arguments) + cancel() // Capture once; each model attempt gets its own timeout below. cfg.Prompt = EffectiveJudgePrompt(cfg.Prompt) var out Decision var err error @@ -461,7 +477,7 @@ func (i *Interceptor) Judge(ctx context.Context, tool string, arguments json.Raw out = Decision{Action: "ask", ModelFallback: true, Message: "审查上下文不完整,需要人工确认:" + contextErr.Error()} } else { modelInput, _ = json.Marshal(input) - out, err = rv(cctx, cfg.ProfileID, cfg.Prompt, input) + out, err = reviewWithRetry(ctx, cfg, rv, input) } if err != nil { out = Decision{ProfileID: out.ProfileID, ModelFallback: true, Action: cfg.FailAction, Message: "模型审批失败,按失败策略处理: " + err.Error()} diff --git a/intercept/judge_retry.go b/intercept/judge_retry.go new file mode 100644 index 0000000..8149fe2 --- /dev/null +++ b/intercept/judge_retry.go @@ -0,0 +1,67 @@ +package intercept + +import ( + "context" + "fmt" + "time" +) + +const ( + MaxJudgeRetryCount = 20 + MaxJudgeRetryIntervalMS = 3600_000 + defaultJudgeRetryIntervalMS = 1000 +) + +// ValidateRetry rejects invalid settings before any judge configuration is saved. +func (c JudgeConfig) ValidateRetry() error { + if c.RetryCount < 0 || c.RetryCount > MaxJudgeRetryCount { + return fmt.Errorf("retry_count 必须在 0 到 %d 之间", MaxJudgeRetryCount) + } + if c.RetryIntervalMS < 0 || c.RetryIntervalMS > MaxJudgeRetryIntervalMS { + return fmt.Errorf("retry_interval_ms 必须在 0 到 %d 之间", MaxJudgeRetryIntervalMS) + } + return nil +} + +// reviewWithRetry retries failed classifications, including invalid verdicts. +// The captured input stays identical across attempts. A valid deny/ask verdict +// is final, and parent cancellation interrupts both requests and the retry wait. +// Provider-level connection/empty-response retries still run within each attempt. +func reviewWithRetry(ctx context.Context, cfg JudgeConfig, rv Reviewer, input ReviewInput) (out Decision, err error) { + for attempt := 0; attempt <= cfg.RetryCount; attempt++ { + if ctx.Err() != nil { + return out, ctx.Err() + } + if attempt > 0 && cfg.RetryIntervalMS > 0 { + timer := time.NewTimer(time.Duration(cfg.RetryIntervalMS) * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return out, ctx.Err() + case <-timer.C: + } + } + if ctx.Err() != nil { + return out, ctx.Err() + } + attemptCtx := ctx + cancel := func() {} + if cfg.TimeoutSeconds > 0 { + attemptCtx, cancel = context.WithTimeout(ctx, time.Duration(cfg.TimeoutSeconds)*time.Second) + } + out, err = rv(attemptCtx, cfg.ProfileID, cfg.Prompt, input) + if attemptCtx.Err() != nil { + err = attemptCtx.Err() + } + cancel() + if err == nil { + switch out.Action { + case "allow", "ask", "deny": + return out, nil + default: + err = fmt.Errorf("模型输出无法解析") + } + } + } + return out, err +} diff --git a/intercept/judge_retry_test.go b/intercept/judge_retry_test.go new file mode 100644 index 0000000..885e96f --- /dev/null +++ b/intercept/judge_retry_test.go @@ -0,0 +1,247 @@ +package intercept + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + "time" + + "github.com/Autumn-27/artex/db" +) + +func retryTestInterceptor(t *testing.T, settings map[string]string) *Interceptor { + t.Helper() + d, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = d.Close() }) + if _, err := d.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT)`); err != nil { + t.Fatal(err) + } + for key, value := range settings { + if _, err := d.Exec(`INSERT INTO settings(key,value) VALUES (?,?)`, key, value); err != nil { + t.Fatal(err) + } + } + return New(&db.DB{DB: d}) +} + +func TestJudgeRetryConfigDefaultsAndBounds(t *testing.T) { + for _, tc := range []struct { + name string + settings map[string]string + count, interval int + }{ + {"legacy", nil, 0, 1000}, + {"configured", map[string]string{settingJudgeRetryCount: "3", settingJudgeRetryIntervalMS: "25"}, 3, 25}, + {"immediate", map[string]string{settingJudgeRetryCount: "1", settingJudgeRetryIntervalMS: "0"}, 1, 0}, + {"malformed", map[string]string{settingJudgeRetryCount: "bad", settingJudgeRetryIntervalMS: "bad"}, 0, 1000}, + {"negative", map[string]string{settingJudgeRetryCount: "-1", settingJudgeRetryIntervalMS: "-1"}, 0, 0}, + {"excessive", map[string]string{settingJudgeRetryCount: "999", settingJudgeRetryIntervalMS: "999999999"}, 20, 3600000}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := retryTestInterceptor(t, tc.settings).GetJudgeConfig() + if cfg.RetryCount != tc.count || cfg.RetryIntervalMS != tc.interval { + t.Fatalf("config=%+v", cfg) + } + }) + } +} + +func TestJudgeRetriesBeforeApplyingFailurePolicy(t *testing.T) { + for _, tc := range []struct { + name, failAction, verdict string + failures, wantCalls int + wantAction string + wantFallback bool + }{ + {"recover", "deny", "allow", 2, 3, "allow", false}, + {"valid_deny_is_final", "allow", "deny", 0, 1, "deny", false}, + {"valid_ask_is_final", "allow", "ask", 0, 1, "ask", false}, + {"exhaust_allow", "allow", "allow", 9, 3, "allow", true}, + {"exhaust_ask", "ask", "allow", 9, 3, "ask", true}, + {"exhaust_deny", "deny", "allow", 9, 3, "deny", true}, + } { + t.Run(tc.name, func(t *testing.T) { + i := retryTestInterceptor(t, map[string]string{ + settingJudgeEnabled: "true", settingJudgeRetryCount: "2", settingJudgeRetryIntervalMS: "0", settingJudgeFailAction: tc.failAction, + }) + calls := 0 + var first ReviewInput + i.SetReviewer(func(ctx context.Context, id int64, prompt string, input ReviewInput) (Decision, error) { + calls++ + if ctx.Err() != nil || prompt == "" { + t.Fatal("invalid request context") + } + if calls == 1 { + first = input + } else if !reflect.DeepEqual(input, first) { + t.Fatal("review input changed on retry") + } + out := Decision{Action: tc.verdict, ProfileID: 42, Message: "review result"} + if calls <= tc.failures { + return out, errors.New("temporary model failure") + } + return out, nil + }) + got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`)) + if !ok || calls != tc.wantCalls || got.Action != tc.wantAction || got.ModelFallback != tc.wantFallback || got.ProfileID != 42 { + t.Fatalf("calls=%d decision=%+v ok=%v", calls, got, ok) + } + if got.RuleID != 0 || len(got.ModelInput) == 0 || got.ModelInputDigest == "" || got.ConfigDigest == "" { + t.Fatalf("missing audit data: %+v", got) + } + if got.Action == "ask" && (!got.TimeoutEnabled || got.TimeoutSeconds != 300 || got.TimeoutAction != "deny") { + t.Fatal("human approval timeout lost") + } + if tc.wantFallback && !strings.Contains(got.Message, "temporary model failure") { + t.Fatal("last failure lost") + } + }) + } +} + +func TestJudgeRetriesInvalidVerdict(t *testing.T) { + for _, invalid := range []string{"", "invalid"} { + calls := 0 + out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1}, func(context.Context, int64, string, ReviewInput) (Decision, error) { + calls++ + if calls == 1 { + return Decision{Action: invalid}, nil + } + return Decision{Action: "deny"}, nil + }, ReviewInput{}) + if err != nil || out.Action != "deny" || calls != 2 { + t.Fatalf("calls=%d out=%+v err=%v", calls, out, err) + } + } +} + +func TestJudgeLegacyConfigDoesNotRetry(t *testing.T) { + i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeFailAction: "deny"}) + calls := 0 + i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) { + calls++ + return Decision{}, errors.New("failed") + }) + got, _ := i.Judge(t.Context(), "Bash", json.RawMessage(`{"command":"echo ok"}`)) + if calls != 1 || got.Action != "deny" || !got.ModelFallback { + t.Fatalf("calls=%d out=%+v", calls, got) + } +} + +func TestJudgeRetryTimeoutGetsFreshContext(t *testing.T) { + calls := 0 + var previous context.Context + out, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, TimeoutSeconds: 1}, func(ctx context.Context, _ int64, _ string, _ ReviewInput) (Decision, error) { + calls++ + if calls == 1 { + previous = ctx + <-ctx.Done() + return Decision{}, ctx.Err() + } + if previous.Err() != context.DeadlineExceeded || ctx.Err() != nil { + t.Fatal("retry reused expired context") + } + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) < 500*time.Millisecond { + t.Fatal("retry has no fresh timeout") + } + return Decision{Action: "allow"}, nil + }, ReviewInput{}) + if calls != 2 || err != nil || out.Action != "allow" { + t.Fatalf("calls=%d out=%+v err=%v", calls, out, err) + } + if previous.Err() == nil { + t.Fatal("attempt context was not released") + } +} + +func TestJudgeRetryCancellationInterruptsWait(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + started := make(chan struct{}) + done := make(chan error, 1) + calls := 0 + go func() { + _, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 20, RetryIntervalMS: 3600000}, func(context.Context, int64, string, ReviewInput) (Decision, error) { + calls++ + close(started) + return Decision{}, errors.New("failed") + }, ReviewInput{}) + done <- err + }() + <-started + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) || calls != 1 { + t.Fatalf("calls=%d err=%v", calls, err) + } + case <-time.After(time.Second): + t.Fatal("cancellation did not interrupt retry wait") + } +} + +func TestJudgeRetryDoesNotCallCanceledOrDisabledReviewer(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + cancel() + _, err := reviewWithRetry(ctx, JudgeConfig{RetryCount: 2}, func(context.Context, int64, string, ReviewInput) (Decision, error) { + t.Fatal("called after cancellation") + return Decision{}, nil + }, ReviewInput{}) + if !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + i := retryTestInterceptor(t, nil) + i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) { + t.Fatal("called disabled reviewer") + return Decision{}, nil + }) + if _, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{}`)); ok { + t.Fatal("disabled judge returned verdict") + } +} + +func TestJudgeRetryWaitsBetweenFailures(t *testing.T) { + var first time.Time + calls := 0 + _, err := reviewWithRetry(t.Context(), JudgeConfig{RetryCount: 1, RetryIntervalMS: 20}, func(context.Context, int64, string, ReviewInput) (Decision, error) { + calls++ + if calls == 1 { + first = time.Now() + return Decision{}, errors.New("failed") + } + if time.Since(first) < 20*time.Millisecond { + t.Fatal("retry skipped configured interval") + } + return Decision{Action: "allow"}, nil + }, ReviewInput{}) + if err != nil || calls != 2 { + t.Fatalf("calls=%d err=%v", calls, err) + } +} + +func TestJudgeInvalidArgumentsDoNotCallReviewer(t *testing.T) { + i := retryTestInterceptor(t, map[string]string{settingJudgeEnabled: "true", settingJudgeRetryCount: "2"}) + i.SetReviewer(func(context.Context, int64, string, ReviewInput) (Decision, error) { + t.Fatal("invalid input must go straight to human review") + return Decision{}, nil + }) + got, ok := i.Judge(t.Context(), "Bash", json.RawMessage(`{broken`)) + if !ok || got.Action != "ask" || !got.ModelFallback { + t.Fatalf("out=%+v ok=%v", got, ok) + } +} + +func TestJudgeRejectsInvalidRetryBeforeWritingSettings(t *testing.T) { + for _, cfg := range []JudgeConfig{{RetryCount: -1}, {RetryCount: 21}, {RetryIntervalMS: -1}, {RetryIntervalMS: 3600001}} { + if err := New(nil).SetJudgeConfig(cfg); err == nil { + t.Fatalf("accepted invalid retry: %+v", cfg) + } + } +} diff --git a/server/intercept.go b/server/intercept.go index 76ad22f..4a6cc9a 100644 --- a/server/intercept.go +++ b/server/intercept.go @@ -474,6 +474,10 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request) writeErr(w, 400, "incomplete_script_action 必须是 ask 或 model") return } + if err := req.ValidateRetry(); err != nil { + writeErr(w, 400, err.Error()) + return + } if err := s.m.interceptor.SetJudgeConfig(req); err != nil { writeErr(w, 500, err.Error()) return diff --git a/server/intercept_config_test.go b/server/intercept_config_test.go index 97312d0..88aabde 100644 --- a/server/intercept_config_test.go +++ b/server/intercept_config_test.go @@ -1,13 +1,18 @@ package server import ( + "database/sql" + "database/sql/driver" + "encoding/json" "net/http/httptest" "os" "strings" + "sync" "testing" "github.com/Autumn-27/artex/db" "github.com/Autumn-27/artex/intercept" + "modernc.org/sqlite" ) func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) { @@ -20,7 +25,7 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) { t.Fatal(err) } t.Cleanup(func() { _ = pg.Close() }) - keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"} + keys := []string{"llm_judge_enabled", "llm_judge_profile_id", "llm_judge_prompt", "llm_judge_timeout_seconds", "llm_judge_retry_count", "llm_judge_retry_interval_ms", "llm_judge_fail_action", "llm_judge_ask_timeout_seconds", "llm_judge_ask_timeout_action", "llm_judge_incomplete_script_action"} for _, key := range keys { value, exists, err := pg.GetSetting(key) if err != nil { @@ -47,14 +52,80 @@ func TestJudgeConfigIncompleteScriptDefaultAndOverride(t *testing.T) { {`,"incomplete_script_action":"model"`, "model", 200}, {`,"incomplete_script_action":"invalid"`, "model", 400}, } { - req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny"`+tc.option+`}`)) + req := httptest.NewRequest("PUT", "/api/intercept/judge-config", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny","retry_count":2,"retry_interval_ms":17`+tc.option+`}`)) w := httptest.NewRecorder() s.interceptSetJudgeConfig(w, req) if w.Code != tc.code { t.Fatalf("option=%s status=%d body=%s", tc.option, w.Code, w.Body.String()) } + if cfg := s.m.interceptor.GetJudgeConfig(); cfg.RetryCount != 2 || cfg.RetryIntervalMS != 17 { + t.Fatalf("retry configuration not persisted: %+v", cfg) + } if got := s.m.interceptor.GetJudgeConfig().IncompleteScriptAction; got != tc.want { t.Fatalf("option=%s action=%s want=%s", tc.option, got, tc.want) } } } + +func TestJudgeConfigRejectsInvalidRetryBeforeSaving(t *testing.T) { + // No manager is needed: invalid input must be rejected before database writes. + s := &Server{} + for _, fields := range []string{ + `"retry_count":-1`, `"retry_count":21`, `"retry_count":1.5`, + `"retry_interval_ms":-1`, `"retry_interval_ms":3600001`, `"retry_interval_ms":1.5`, + } { + t.Run(fields, func(t *testing.T) { + req := httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(`{"fail_action":"allow","ask_timeout_action":"deny",`+fields+`}`)) + w := httptest.NewRecorder() + s.interceptSetJudgeConfig(w, req) + if w.Code != 400 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + }) + } +} + +var judgeConfigSQLiteNow sync.Once + +func TestJudgeConfigRetryRoundTrip(t *testing.T) { + // Exercise production settings SQL without requiring an external PG service. + judgeConfigSQLiteNow.Do(func() { + sqlite.MustRegisterDeterministicScalarFunction("now", 0, func(*sqlite.FunctionContext, []driver.Value) (driver.Value, error) { + return "2026-01-01 00:00:00", nil + }) + }) + sqldb, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqldb.Close() }) + if _, err := sqldb.Exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT, updated_at TEXT)`); err != nil { + t.Fatal(err) + } + pg := &db.DB{DB: sqldb} + s := &Server{m: &Manager{pg: pg, interceptor: intercept.New(pg)}} + for _, tc := range []struct{ count, interval int }{{2, 17}, {20, 3600000}, {0, 0}} { + cfg := s.m.interceptor.GetJudgeConfig() + cfg.RetryCount, cfg.RetryIntervalMS = tc.count, tc.interval + body, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + w := httptest.NewRecorder() + s.interceptSetJudgeConfig(w, httptest.NewRequest("PUT", "/api/intercept/judge", strings.NewReader(string(body)))) + if w.Code != 200 { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + // Construct a fresh interceptor to prove persistence, rather than cache reuse. + s.m.interceptor = intercept.New(pg) + w = httptest.NewRecorder() + s.interceptGetJudgeConfig(w, httptest.NewRequest("GET", "/api/intercept/judge", nil)) + var got intercept.JudgeConfig + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.RetryCount != tc.count || got.RetryIntervalMS != tc.interval { + t.Fatalf("saved=%+v got=%+v", cfg, got) + } + } +} diff --git a/web/src/app/(main)/system/intercept/page.tsx b/web/src/app/(main)/system/intercept/page.tsx index 82024f2..2090cb2 100644 --- a/web/src/app/(main)/system/intercept/page.tsx +++ b/web/src/app/(main)/system/intercept/page.tsx @@ -21,6 +21,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; +import { Field as FormField, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Label } from "@/components/ui/label"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; @@ -200,6 +201,8 @@ const defaultJudge = (): JudgeConfig => ({ profile_id: 0, prompt: "", timeout_seconds: 15, + retry_count: 0, + retry_interval_ms: 1000, fail_action: "allow", ask_timeout_seconds: 300, ask_timeout_action: "deny", @@ -207,7 +210,7 @@ const defaultJudge = (): JudgeConfig => ({ }); function JudgeCard() { - const [cfg, setCfg] = React.useState(defaultJudge()); + const [cfg, setCfg] = React.useState(defaultJudge); const [profiles, setProfiles] = React.useState([]); const [loading, setLoading] = React.useState(true); const [saving, setSaving] = React.useState(false); @@ -376,7 +379,7 @@ function JudgeCard() { - + - + + + 失败重试次数 + { + const n = Number(e.target.value); + if (Number.isInteger(n) && n >= 0 && n <= 20) patch({ retry_count: n }); + }} + aria-describedby="judge-retry-count-hint" + /> + + 首次审批失败后额外重试 0–20 次,0 表示不重试。出错、超时或裁决无法解析时重试;正常放行、转人工或拦截后立即结束。 + + + + 重试间隔(毫秒) + { + const n = Number(e.target.value); + if (Number.isInteger(n) && n >= 0 && n <= 3600000) patch({ retry_interval_ms: n }); + }} + aria-describedby="judge-retry-interval-hint" + /> + + 默认 1000 毫秒,0 表示立即重试。每次审批均有独立超时,全部尝试失败后执行下方失败策略。模型配置中的建连和空响应重试仍在每次审批内生效。 + + + +