Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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` 字段

| 字段 | 说明 |
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type Config struct {
Workdir string `yaml:"-"`
Shell string `yaml:"shell"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces a new persisted/validated config surface (runtime.max_no_progress_streak), but the config docs are not updated in this PR. Please update the configuration docs (fields, defaults, and example YAML) so implementation and docs stay in sync.

Runtime RuntimeConfig `yaml:"runtime,omitempty"`
Context ContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
Memo MemoConfig `yaml:"memo,omitempty"`
Expand All @@ -32,6 +33,7 @@ func StaticDefaults() *Config {
Workdir: DefaultWorkdir,
Shell: defaultShell(),
ToolTimeoutSec: DefaultToolTimeoutSec,
Runtime: defaultRuntimeConfig(),
Context: defaultContextConfig(),
Tools: ToolsConfig{
WebFetch: defaultWebFetchConfig(),
Expand All @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down
49 changes: 49 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1592,6 +1592,9 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) {
SupportedContentTypes: []string{"text/html"},
},
},
Runtime: RuntimeConfig{
MaxNoProgressStreak: 3,
},
Context: ContextConfig{
Compact: CompactConfig{
ManualStrategy: "invalid_strategy",
Expand Down Expand Up @@ -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()

Expand Down
3 changes: 3 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down Expand Up @@ -204,6 +205,7 @@ func parseCurrentConfig(data []byte, contextDefaults ContextConfig, memoDefaults
CurrentModel: strings.TrimSpace(file.CurrentModel),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RuntimeConfig is now part of parse/save/validate flow, but there are no focused tests for this field’s round-trip and invalid-value behavior (e.g. parse + marshal + validate for max_no_progress_streak). Please add targeted coverage to prevent regressions.

Shell: strings.TrimSpace(file.Shell),
ToolTimeoutSec: file.ToolTimeoutSec,
Runtime: file.Runtime,
Context: fromPersistedContextConfig(file.Context, contextDefaults),
Tools: file.Tools,
Memo: fromPersistedMemoConfig(file.Memo, memoDefaults),
Expand All @@ -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),
Expand Down
44 changes: 44 additions & 0 deletions internal/config/runtime.go
Original file line number Diff line number Diff line change
@@ -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
}
48 changes: 48 additions & 0 deletions internal/config/runtime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
55 changes: 37 additions & 18 deletions internal/runtime/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 不会出现状态覆盖;
// 新会话在创建后再绑定会话锁,不同会话可并行执行。
Expand Down Expand Up @@ -131,33 +133,25 @@ 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
state.mu.Unlock()

s.emitRunScoped(ctx, EventProgressEvaluated, &state, ProgressEvaluatedPayload{Score: currentScore})

if streak >= noProgressStreakLimit {
limit := snapshot.noProgressStreakLimit
if streak >= limit {
err = ErrNoProgressStreakLimit
return err
}
Expand Down Expand Up @@ -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,
Expand Down
1 change: 0 additions & 1 deletion internal/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ const (
providerRetryBaseWait = 1 * time.Second
providerRetryMaxWait = 5 * time.Second
defaultToolParallelism = 4
noProgressStreakLimit = 3

terminationEventEmitTimeout = 500 * time.Millisecond
)
Expand Down
9 changes: 9 additions & 0 deletions internal/runtime/runtime_progress_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package runtime
import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"

Expand Down Expand Up @@ -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", "{}")
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading