diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index b2a407c0..0d8724ca 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`;streak 达到 `limit-1`(默认第 2 轮)时向模型注入一次系统级纠偏提示,达到 `limit`(默认第 3 轮)时终止运行 | + ### `tools` 字段 | 字段 | 说明 | 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..ffd0067c 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", @@ -1682,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/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..86bd20ce --- /dev/null +++ b/internal/config/runtime.go @@ -0,0 +1,44 @@ +package config + +import ( + "errors" +) + +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 + } + if c.MaxNoProgressStreak <= 0 { + c.MaxNoProgressStreak = defaults.MaxNoProgressStreak + } +} + +// Validate 校验 runtime 配置是否满足最小约束。 +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/config/runtime_test.go b/internal/config/runtime_test.go new file mode 100644 index 00000000..0c6c705d --- /dev/null +++ b/internal/config/runtime_test.go @@ -0,0 +1,48 @@ +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) + } + + 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 ba5c13f8..34c9a35a 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 不会出现状态覆盖; // 新会话在创建后再绑定会话锁,不同会话可并行执行。 @@ -131,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 @@ -157,7 +150,8 @@ func (s *Service) Run(ctx context.Context, input UserInput) (err error) { s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore}) - if streak >= noProgressStreakLimit { + limit := snapshot.noProgressStreakLimit + if streak >= limit { err = ErrNoProgressStreakLimit return err } @@ -216,22 +210,47 @@ 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 := resolveNoProgressStreakLimit(cfg.Runtime) + systemPrompt := builtContext.SystemPrompt + + if streak == limit-1 { + trimmed := strings.TrimSpace(systemPrompt) + if trimmed == "" { + systemPrompt = selfHealingReminder + } else { + 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: builtContext.SystemPrompt, + SystemPrompt: systemPrompt, Messages: builtContext.Messages, Tools: toolSpecs, }, }, false, nil } +// resolveNoProgressStreakLimit 统一解析熔断阈值,避免运行期出现无效值导致分支行为不一致。 +func resolveNoProgressStreakLimit(rc config.RuntimeConfig) int { + if rc.MaxNoProgressStreak <= 0 { + return config.DefaultMaxNoProgressStreak + } + return rc.MaxNoProgressStreak +} + // callProviderWithRetry 使用冻结后的 turnSnapshot 执行 provider 调用与必要重试。 func (s *Service) callProviderWithRetry( ctx context.Context, 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..f9e90a1a 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,13 @@ 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 { + if strings.Contains(req.SystemPrompt, selfHealingReminder) { + 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 +88,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) { 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 调用成功后的结构化结果。