From d7c1aac6f2b9fdcf331ab56334dffdab2a788db7 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Wed, 15 Apr 2026 01:17:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(runtime):=E5=A2=9E=E5=8A=A0=E5=8F=AF?= =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=AD=BB=E5=BE=AA=E7=8E=AF=E6=AC=A1=E6=95=B0?= =?UTF-8?q?=E4=BB=A5=E5=8F=8A=E8=87=AA=E6=88=91=E7=BA=A0=E6=AD=A3=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config.go | 7 ++++ internal/config/config_test.go | 3 ++ internal/config/loader.go | 3 ++ internal/config/runtime.go | 39 +++++++++++++++++++++++ internal/runtime/run.go | 26 +++++++++++++-- internal/runtime/runtime.go | 1 - internal/runtime/runtime_progress_test.go | 11 +++++++ 7 files changed, 87 insertions(+), 3 deletions(-) create mode 100644 internal/config/runtime.go diff --git a/internal/config/config.go b/internal/config/config.go index 7d0d5c07..87ce5d0f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -21,6 +21,7 @@ type Config struct { Workdir string `yaml:"-"` Shell string `yaml:"shell"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Runtime RuntimeConfig `yaml:"runtime,omitempty"` Context ContextConfig `yaml:"context,omitempty"` Tools ToolsConfig `yaml:"tools,omitempty"` Memo MemoConfig `yaml:"memo,omitempty"` @@ -32,6 +33,7 @@ func StaticDefaults() *Config { Workdir: DefaultWorkdir, Shell: defaultShell(), ToolTimeoutSec: DefaultToolTimeoutSec, + Runtime: defaultRuntimeConfig(), Context: defaultContextConfig(), Tools: ToolsConfig{ WebFetch: defaultWebFetchConfig(), @@ -48,6 +50,7 @@ func (c *Config) Clone() Config { clone := *c clone.Providers = cloneProviders(c.Providers) + clone.Runtime = c.Runtime.Clone() clone.Context = c.Context.Clone() clone.Tools = c.Tools.Clone() clone.Memo = c.Memo.Clone() @@ -69,6 +72,7 @@ func (c *Config) applyStaticDefaults(defaults Config) { if c.ToolTimeoutSec <= 0 { c.ToolTimeoutSec = defaults.ToolTimeoutSec } + c.Runtime.ApplyDefaults(defaults.Runtime) c.Context.ApplyDefaults(defaults.Context) c.Tools.ApplyDefaults(defaults.Tools) c.Memo.ApplyDefaults(defaults.Memo) @@ -122,6 +126,9 @@ func (c *Config) ValidateSnapshot() error { if err := c.Tools.Validate(); err != nil { return fmt.Errorf("config: tools: %w", err) } + if err := c.Runtime.Validate(); err != nil { + return fmt.Errorf("config: runtime: %w", err) + } if err := c.Context.Validate(); err != nil { return fmt.Errorf("config: context: %w", err) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f7b5de4e..b62b2092 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1592,6 +1592,9 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) { SupportedContentTypes: []string{"text/html"}, }, }, + Runtime: RuntimeConfig{ + MaxNoProgressStreak: 3, + }, Context: ContextConfig{ Compact: CompactConfig{ ManualStrategy: "invalid_strategy", diff --git a/internal/config/loader.go b/internal/config/loader.go index c7fba9b4..878fd9af 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -26,6 +26,7 @@ type persistedConfig struct { CurrentModel string `yaml:"current_model,omitempty"` Shell string `yaml:"shell"` ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"` + Runtime RuntimeConfig `yaml:"runtime,omitempty"` Context persistedContextConfig `yaml:"context,omitempty"` Tools ToolsConfig `yaml:"tools,omitempty"` Memo persistedMemoConfig `yaml:"memo,omitempty"` @@ -204,6 +205,7 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults CurrentModel: strings.TrimSpace(file.CurrentModel), Shell: strings.TrimSpace(file.Shell), ToolTimeoutSec: file.ToolTimeoutSec, + Runtime: file.Runtime, Context: fromPersistedContextConfig(file.Context, contextDefaults), Tools: file.Tools, Memo: fromPersistedMemoConfig(file.Memo, memoDefaults), @@ -218,6 +220,7 @@ func marshalPersistedConfig(snapshot Config) ([]byte, error) { CurrentModel: snapshot.CurrentModel, Shell: snapshot.Shell, ToolTimeoutSec: snapshot.ToolTimeoutSec, + Runtime: snapshot.Runtime, Context: newPersistedContextConfig(snapshot.Context), Tools: snapshot.Tools, Memo: newPersistedMemoConfig(snapshot.Memo), diff --git a/internal/config/runtime.go b/internal/config/runtime.go new file mode 100644 index 00000000..a8f4da5b --- /dev/null +++ b/internal/config/runtime.go @@ -0,0 +1,39 @@ +package config + +import ( + "errors" +) + +const ( + DefaultMaxNoProgressStreak = 3 +) + +type RuntimeConfig struct { + MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` +} + +func defaultRuntimeConfig() RuntimeConfig { + return RuntimeConfig{ + MaxNoProgressStreak: DefaultMaxNoProgressStreak, + } +} + +func (c RuntimeConfig) Clone() RuntimeConfig { + return c +} + +func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { + if c == nil { + return + } + if c.MaxNoProgressStreak <= 0 { + c.MaxNoProgressStreak = defaults.MaxNoProgressStreak + } +} + +func (c RuntimeConfig) Validate() error { + if c.MaxNoProgressStreak <= 0 { + return errors.New("max_no_progress_streak must be greater than 0") + } + return nil +} diff --git a/internal/runtime/run.go b/internal/runtime/run.go index ba5c13f8..7ee2fb7d 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -157,7 +157,12 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) - if streak >= noProgressStreakLimit { + limit := snapshot.config.Runtime.MaxNoProgressStreak + if limit <= 0 { + limit = config.DefaultMaxNoProgressStreak + } + + if streak >= limit { err = ErrNoProgressStreakLimit return err } @@ -216,6 +221,23 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur return turnSnapshot{}, false, err } + state.mu.Lock() + streak := state.progress.LastScore.NoProgressStreak + state.mu.Unlock() + + limit := cfg.Runtime.MaxNoProgressStreak + if limit <= 0 { + limit = config.DefaultMaxNoProgressStreak + } + + messages := builtContext.Messages + if streak == limit-1 { + messages = append(messages, providertypes.Message{ + Role: providertypes.RoleUser, + Content: "System Reminder: You have made multiple consecutive attempts without making substantial progress. Please stop your current repetitive or ineffective strategy. Carefully review the previous errors, change your approach, or ask the user directly for help.", + }) + } + model := strings.TrimSpace(cfg.CurrentModel) return turnSnapshot{ config: cfg, @@ -226,7 +248,7 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur request: providertypes.GenerateRequest{ Model: model, SystemPrompt: builtContext.SystemPrompt, - Messages: builtContext.Messages, + Messages: messages, Tools: toolSpecs, }, }, false, nil diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 1a345f5a..86733bb2 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -20,7 +20,6 @@ const ( providerRetryBaseWait = 1 * time.Second providerRetryMaxWait = 5 * time.Second defaultToolParallelism = 4 - noProgressStreakLimit = 3 terminationEventEmitTimeout = 500 * time.Millisecond ) diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index f83f73f2..141944d5 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -3,6 +3,7 @@ package runtime import ( "context" "errors" + "strings" "sync/atomic" "testing" @@ -31,9 +32,15 @@ func TestProgressStreakStopsRun(t *testing.T) { }, } + var promptInjected bool providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { + for _, msg := range req.Messages { + if strings.Contains(msg.Content, "System Reminder: You have made multiple consecutive attempts") { + promptInjected = true + } + } // the model always decides to call the tool events <- providertypes.NewToolCallStartStreamEvent(0, "call_err", "tool_error") events <- providertypes.NewToolCallDeltaStreamEvent(0, "call_err", "{}") @@ -83,6 +90,10 @@ func TestProgressStreakStopsRun(t *testing.T) { } } } + + if !promptInjected { + t.Error("expected self-healing prompt to be injected before streak limit is reached, but it wasn't") + } } func TestProgressEvidenceResetsNoProgressStreak(t *testing.T) { From 4098da8d1cc669da6537e8e0ace8bcee0dceeab2 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Tue, 14 Apr 2026 17:43:39 +0000 Subject: [PATCH 2/3] fix(runtime,config): resolve PR review items and simplify streak handling Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/guides/configuration.md | 8 ++++ internal/config/config_test.go | 46 +++++++++++++++++++++++ internal/config/runtime.go | 5 +++ internal/config/runtime_test.go | 46 +++++++++++++++++++++++ internal/runtime/run.go | 37 ++++++++++-------- internal/runtime/runtime_progress_test.go | 6 +-- 6 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 internal/config/runtime_test.go diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index b2a407c0..c6a9a4a3 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -38,6 +38,8 @@ selected_provider: openai current_model: gpt-5.4 shell: bash tool_timeout_sec: 20 +runtime: + max_no_progress_streak: 3 tools: webfetch: @@ -80,6 +82,12 @@ context: | `context.auto_compact.enabled` | 是否启用自动压缩 | | `context.auto_compact.input_token_threshold` | 自动压缩输入 token 阈值 | +### `runtime` 字段 + +| 字段 | 说明 | +|------|------| +| `runtime.max_no_progress_streak` | 连续“无进展”轮次熔断阈值,默认 `3`;当达到 `limit-1` 时会向模型注入一次系统级纠偏提示 | + ### `tools` 字段 | 字段 | 说明 | diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b62b2092..ffd0067c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1685,6 +1685,52 @@ func TestMarshalPersistedConfigEndsWithNewline(t *testing.T) { } } +func TestParseCurrentConfigRoundTripRuntimeConfig(t *testing.T) { + t.Parallel() + + snapshot := testDefaultConfig().Clone() + snapshot.Runtime.MaxNoProgressStreak = 5 + + data, err := marshalPersistedConfig(snapshot) + if err != nil { + t.Fatalf("marshalPersistedConfig() error = %v", err) + } + + parsed, err := parseCurrentConfig(data, StaticDefaults().Context, StaticDefaults().Memo) + if err != nil { + t.Fatalf("parseCurrentConfig() error = %v", err) + } + if parsed.Runtime.MaxNoProgressStreak != 5 { + t.Fatalf("expected max_no_progress_streak=5, got %d", parsed.Runtime.MaxNoProgressStreak) + } +} + +func TestParseCurrentConfigInvalidRuntimeValueDefaultsBeforeValidation(t *testing.T) { + t.Parallel() + + raw := []byte(` +selected_provider: openai +current_model: gpt-4.1 +shell: bash +runtime: + max_no_progress_streak: -2 +`) + + parsed, err := parseCurrentConfig(raw, StaticDefaults().Context, StaticDefaults().Memo) + if err != nil { + t.Fatalf("parseCurrentConfig() error = %v", err) + } + parsed.Providers = cloneProviders(testDefaultConfig().Providers) + parsed.applyStaticDefaults(*StaticDefaults()) + if err := parsed.ValidateSnapshot(); err != nil { + t.Fatalf("ValidateSnapshot() error = %v", err) + } + if parsed.Runtime.MaxNoProgressStreak != DefaultMaxNoProgressStreak { + t.Fatalf("expected default max_no_progress_streak=%d, got %d", + DefaultMaxNoProgressStreak, parsed.Runtime.MaxNoProgressStreak) + } +} + func TestAssembleProvidersAcceptsEmptyNameProvider(t *testing.T) { t.Parallel() diff --git a/internal/config/runtime.go b/internal/config/runtime.go index a8f4da5b..86bd20ce 100644 --- a/internal/config/runtime.go +++ b/internal/config/runtime.go @@ -8,20 +8,24 @@ const ( DefaultMaxNoProgressStreak = 3 ) +// RuntimeConfig 定义 runtime 层的可调参数。 type RuntimeConfig struct { MaxNoProgressStreak int `yaml:"max_no_progress_streak,omitempty"` } +// defaultRuntimeConfig 返回 runtime 配置的静态默认值。 func defaultRuntimeConfig() RuntimeConfig { return RuntimeConfig{ MaxNoProgressStreak: DefaultMaxNoProgressStreak, } } +// Clone 复制 runtime 配置,避免调用方共享可变状态。 func (c RuntimeConfig) Clone() RuntimeConfig { return c } +// ApplyDefaults 在配置缺失或非法时回填默认阈值。 func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { if c == nil { return @@ -31,6 +35,7 @@ func (c *RuntimeConfig) ApplyDefaults(defaults RuntimeConfig) { } } +// Validate 校验 runtime 配置是否满足最小约束。 func (c RuntimeConfig) Validate() error { if c.MaxNoProgressStreak <= 0 { return errors.New("max_no_progress_streak must be greater than 0") diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go new file mode 100644 index 00000000..f7126648 --- /dev/null +++ b/internal/config/runtime_test.go @@ -0,0 +1,46 @@ +package config + +import "testing" + +func TestRuntimeConfigClone(t *testing.T) { + t.Parallel() + + cfg := RuntimeConfig{MaxNoProgressStreak: 7} + cloned := cfg.Clone() + if cloned.MaxNoProgressStreak != 7 { + t.Fatalf("expected cloned MaxNoProgressStreak=7, got %d", cloned.MaxNoProgressStreak) + } +} + +func TestRuntimeConfigApplyDefaults(t *testing.T) { + t.Parallel() + + defaults := RuntimeConfig{MaxNoProgressStreak: 3} + + cfg := RuntimeConfig{MaxNoProgressStreak: 0} + cfg.ApplyDefaults(defaults) + if cfg.MaxNoProgressStreak != 3 { + t.Fatalf("expected defaulted MaxNoProgressStreak=3, got %d", cfg.MaxNoProgressStreak) + } + + cfg = RuntimeConfig{MaxNoProgressStreak: 5} + cfg.ApplyDefaults(defaults) + if cfg.MaxNoProgressStreak != 5 { + t.Fatalf("expected existing MaxNoProgressStreak=5 to be preserved, got %d", cfg.MaxNoProgressStreak) + } + + var nilCfg *RuntimeConfig + nilCfg.ApplyDefaults(defaults) +} + +func TestRuntimeConfigValidate(t *testing.T) { + t.Parallel() + + if err := (RuntimeConfig{MaxNoProgressStreak: 1}).Validate(); err != nil { + t.Fatalf("expected valid config, got %v", err) + } + + if err := (RuntimeConfig{MaxNoProgressStreak: 0}).Validate(); err == nil { + t.Fatal("expected validation error for zero MaxNoProgressStreak") + } +} diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 7ee2fb7d..9ee10856 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -18,6 +18,8 @@ import ( "neo-code/internal/tools" ) +const selfHealingReminder = "System Reminder: You have made multiple consecutive attempts without making substantial progress. Please stop your current repetitive or ineffective strategy. Carefully review the previous errors, change your approach, or ask the user directly for help." + // Run 执行一次完整的 ReAct 闭环:保存用户输入、驱动模型、执行工具并发出事件。 // 已有会话会先加锁再加载/更新,确保同一会话并发 Run 不会出现状态覆盖; // 新会话在创建后再绑定会话锁,不同会话可并行执行。 @@ -157,11 +159,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) - limit := snapshot.config.Runtime.MaxNoProgressStreak - if limit <= 0 { - limit = config.DefaultMaxNoProgressStreak - } - + limit := resolveNoProgressStreakLimit(snapshot.config) if streak >= limit { err = ErrNoProgressStreakLimit return err @@ -225,17 +223,15 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur streak := state.progress.LastScore.NoProgressStreak state.mu.Unlock() - limit := cfg.Runtime.MaxNoProgressStreak - if limit <= 0 { - limit = config.DefaultMaxNoProgressStreak - } + limit := resolveNoProgressStreakLimit(cfg) + systemPrompt := builtContext.SystemPrompt - messages := builtContext.Messages if streak == limit-1 { - messages = append(messages, providertypes.Message{ - Role: providertypes.RoleUser, - Content: "System Reminder: You have made multiple consecutive attempts without making substantial progress. Please stop your current repetitive or ineffective strategy. Carefully review the previous errors, change your approach, or ask the user directly for help.", - }) + if strings.TrimSpace(systemPrompt) == "" { + systemPrompt = selfHealingReminder + } else { + systemPrompt = systemPrompt + "\n\n" + selfHealingReminder + } } model := strings.TrimSpace(cfg.CurrentModel) @@ -247,13 +243,22 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur toolTimeout: time.Duration(cfg.ToolTimeoutSec) * time.Second, request: providertypes.GenerateRequest{ Model: model, - SystemPrompt: builtContext.SystemPrompt, - Messages: messages, + SystemPrompt: systemPrompt, + Messages: builtContext.Messages, Tools: toolSpecs, }, }, false, nil } +// resolveNoProgressStreakLimit 统一解析熔断阈值,避免运行期出现无效值导致分支行为不一致。 +func resolveNoProgressStreakLimit(cfg config.Config) int { + limit := cfg.Runtime.MaxNoProgressStreak + if limit <= 0 { + return config.DefaultMaxNoProgressStreak + } + return limit +} + // callProviderWithRetry 使用冻结后的 turnSnapshot 执行 provider 调用与必要重试。 func (s *Service) callProviderWithRetry( ctx context.Context, diff --git a/internal/runtime/runtime_progress_test.go b/internal/runtime/runtime_progress_test.go index 141944d5..f9e90a1a 100644 --- a/internal/runtime/runtime_progress_test.go +++ b/internal/runtime/runtime_progress_test.go @@ -36,10 +36,8 @@ func TestProgressStreakStopsRun(t *testing.T) { providerFactory := &scriptedProviderFactory{ provider: &scriptedProvider{ chatFn: func(ctx context.Context, req providertypes.GenerateRequest, events chan<- providertypes.StreamEvent) error { - for _, msg := range req.Messages { - if strings.Contains(msg.Content, "System Reminder: You have made multiple consecutive attempts") { - promptInjected = true - } + if strings.Contains(req.SystemPrompt, selfHealingReminder) { + promptInjected = true } // the model always decides to call the tool events <- providertypes.NewToolCallStartStreamEvent(0, "call_err", "tool_error") From 319b30365e7720dcde320cfa90d5cc6a2fd4e497 Mon Sep 17 00:00:00 2001 From: xgopilot Date: Wed, 15 Apr 2026 02:19:43 +0000 Subject: [PATCH 3/3] refactor(runtime,config): address review feedback on streak handling - Narrow resolveNoProgressStreakLimit param from config.Config to config.RuntimeConfig - Store resolved noProgressStreakLimit in turnSnapshot to ensure reminder injection and termination checks use the same value, eliminating concurrent-reload inconsistency - TrimSpace systemPrompt once before appending self-healing reminder - Merge double lock sections into one in the progress evidence path - Expand TestRuntimeConfigValidate to cover 0, -1, -99 rejection cases - Update docs: concrete limit-1/limit examples (default 2nd/3rd turn) Generated with [codeagent](https://github.com/qbox/codeagent) Co-authored-by: phantom5099 <245659304+phantom5099@users.noreply.github.com> --- docs/guides/configuration.md | 2 +- internal/config/runtime_test.go | 6 +++-- internal/runtime/run.go | 40 +++++++++++++-------------------- internal/runtime/state.go | 15 ++++++++----- 4 files changed, 30 insertions(+), 33 deletions(-) diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index c6a9a4a3..0d8724ca 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -86,7 +86,7 @@ context: | 字段 | 说明 | |------|------| -| `runtime.max_no_progress_streak` | 连续“无进展”轮次熔断阈值,默认 `3`;当达到 `limit-1` 时会向模型注入一次系统级纠偏提示 | +| `runtime.max_no_progress_streak` | 连续”无进展”轮次熔断阈值,默认 `3`;streak 达到 `limit-1`(默认第 2 轮)时向模型注入一次系统级纠偏提示,达到 `limit`(默认第 3 轮)时终止运行 | ### `tools` 字段 diff --git a/internal/config/runtime_test.go b/internal/config/runtime_test.go index f7126648..0c6c705d 100644 --- a/internal/config/runtime_test.go +++ b/internal/config/runtime_test.go @@ -40,7 +40,9 @@ func TestRuntimeConfigValidate(t *testing.T) { t.Fatalf("expected valid config, got %v", err) } - if err := (RuntimeConfig{MaxNoProgressStreak: 0}).Validate(); err == nil { - t.Fatal("expected validation error for zero MaxNoProgressStreak") + for _, bad := range []int{0, -1, -99} { + if err := (RuntimeConfig{MaxNoProgressStreak: bad}).Validate(); err == nil { + t.Fatalf("expected validation error for MaxNoProgressStreak=%d", bad) + } } } diff --git a/internal/runtime/run.go b/internal/runtime/run.go index 9ee10856..34c9a35a 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -133,25 +133,16 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.transitionRunPhase(ctx, &state, controlplane.PhaseVerify) var evidence []controlplane.ProgressEvidenceRecord - hasProgress := false toolCallCount := len(turnResult.assistant.ToolCalls) state.mu.Lock() if len(state.session.Messages) >= toolCallCount { for i := len(state.session.Messages) - toolCallCount; i < len(state.session.Messages); i++ { - msg := state.session.Messages[i] - if msg.Role == providertypes.RoleTool && !msg.IsError { - hasProgress = true + if msg := state.session.Messages[i]; msg.Role == providertypes.RoleTool && !msg.IsError { + evidence = append(evidence, controlplane.ProgressEvidenceRecord{Kind: controlplane.EvidenceNewInfoNonDup}) break } } } - state.mu.Unlock() - - if hasProgress { - evidence = append(evidence, controlplane.ProgressEvidenceRecord{Kind: controlplane.EvidenceNewInfoNonDup}) - } - - state.mu.Lock() state.progress = controlplane.ApplyProgressEvidence(state.progress, evidence) streak := state.progress.LastScore.NoProgressStreak currentScore := state.progress.LastScore @@ -159,7 +150,7 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) - limit := resolveNoProgressStreakLimit(snapshot.config) + limit := snapshot.noProgressStreakLimit if streak >= limit { err = ErrNoProgressStreakLimit return err @@ -223,24 +214,26 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur streak := state.progress.LastScore.NoProgressStreak state.mu.Unlock() - limit := resolveNoProgressStreakLimit(cfg) + limit := resolveNoProgressStreakLimit(cfg.Runtime) systemPrompt := builtContext.SystemPrompt if streak == limit-1 { - if strings.TrimSpace(systemPrompt) == "" { + trimmed := strings.TrimSpace(systemPrompt) + if trimmed == "" { systemPrompt = selfHealingReminder } else { - systemPrompt = systemPrompt + "\n\n" + selfHealingReminder + systemPrompt = trimmed + "\n\n" + selfHealingReminder } } model := strings.TrimSpace(cfg.CurrentModel) return turnSnapshot{ - config: cfg, - providerConfig: resolvedProvider.ToRuntimeConfig(), - model: model, - workdir: activeWorkdir, - toolTimeout: time.Duration(cfg.ToolTimeoutSec) * time.Second, + config: cfg, + providerConfig: resolvedProvider.ToRuntimeConfig(), + model: model, + workdir: activeWorkdir, + toolTimeout: time.Duration(cfg.ToolTimeoutSec) * time.Second, + noProgressStreakLimit: limit, request: providertypes.GenerateRequest{ Model: model, SystemPrompt: systemPrompt, @@ -251,12 +244,11 @@ func (s *Service) prepareTurnSnapshot(ctx context.Context, state *runState) (tur } // resolveNoProgressStreakLimit 统一解析熔断阈值,避免运行期出现无效值导致分支行为不一致。 -func resolveNoProgressStreakLimit(cfg config.Config) int { - limit := cfg.Runtime.MaxNoProgressStreak - if limit <= 0 { +func resolveNoProgressStreakLimit(rc config.RuntimeConfig) int { + if rc.MaxNoProgressStreak <= 0 { return config.DefaultMaxNoProgressStreak } - return limit + return rc.MaxNoProgressStreak } // callProviderWithRetry 使用冻结后的 turnSnapshot 执行 provider 调用与必要重试。 diff --git a/internal/runtime/state.go b/internal/runtime/state.go index c0b160bc..585b97f1 100644 --- a/internal/runtime/state.go +++ b/internal/runtime/state.go @@ -63,13 +63,16 @@ func (s *runState) touchSession() { } // turnSnapshot 冻结单轮推理所需的配置、上下文与 provider 请求。 +// noProgressStreakLimit 由 prepareTurnSnapshot 一次性解析并存储,确保同一轮的 +// 纠偏注入阈值与熔断阈值来自同一配置快照,避免并发 reload 导致阈值不一致。 type turnSnapshot struct { - config config.Config - providerConfig provider.RuntimeConfig - model string - workdir string - toolTimeout time.Duration - request providertypes.GenerateRequest + config config.Config + providerConfig provider.RuntimeConfig + model string + workdir string + toolTimeout time.Duration + noProgressStreakLimit int + request providertypes.GenerateRequest } // providerTurnResult 表示单轮 provider 调用成功后的结构化结果。