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
6 changes: 5 additions & 1 deletion docs/config-management-detail-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
- `workdir`
- `shell`
- `tool_timeout_sec`
- `generate_start_timeout_sec`
- `context`
- `tools`

Expand All @@ -69,7 +70,9 @@ custom provider 来自:
```

当前只接受明确受支持的字段;未知字段会直接报错,不做“旧格式自动迁移”。
`provider.yaml` 只支持平铺字段:`name/driver/base_url/api_key_env/model_source/chat_endpoint_path/discovery_endpoint_path/models`。
`provider.yaml` 只支持平铺字段:`name/driver/base_url/api_key_env/model_source/chat_api_mode/chat_endpoint_path/discovery_endpoint_path/generate_max_retries/generate_idle_timeout_sec/models`。

`generate_start_timeout_sec` 已上移到根 `config.yaml` 顶层,启动 preflight 会自动将缺失字段补写为默认值 `90`。

## 加载流程

Expand Down Expand Up @@ -101,6 +104,7 @@ custom provider 来自:
- `current_model`
- `shell`
- `tool_timeout_sec`
- `generate_start_timeout_sec`
- `context`
- `tools`

Expand Down
4 changes: 4 additions & 0 deletions docs/guides/adding-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ model_source: discover
chat_api_mode: responses
chat_endpoint_path: /
discovery_endpoint_path: /models
generate_max_retries: 5
generate_idle_timeout_sec: 300
```

说明:
Expand All @@ -107,6 +109,8 @@ discovery_endpoint_path: /models
- `chat_endpoint_path` 为 `/` 表示直连 `base_url`;为空时会按 `chat_api_mode` 自动回填默认子路径(`/chat/completions` 或 `/responses`)。
- 当 `chat_api_mode` 已显式指定时,`chat_endpoint_path` 可使用任意以 `/` 开头的相对路径;未显式指定时,仅支持标准端点推断(`/chat/completions`、`/responses`、`/`)。
- `model_source: manual` 时必须提供 `models`,且会忽略 `discovery_endpoint_path`。
- `generate_max_retries` / `generate_idle_timeout_sec` 用于控制 provider 级生成重试和流空闲超时;未填写或 `<= 0` 时会分别回退到 `5 / 300`。其中 `generate_max_retries` 必须 `<= 20`。
- `generate_start_timeout_sec` 已改为根 `config.yaml` 顶层字段,不再允许写入 `provider.yaml`;启动时缺失会自动补写默认值 `90`。

## 测试要求

Expand Down
11 changes: 11 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ selected_provider: openai
current_model: gpt-5.4
shell: bash
tool_timeout_sec: 20
generate_start_timeout_sec: 90

runtime:
max_no_progress_streak: 5
Expand Down Expand Up @@ -173,8 +174,18 @@ base_url: https://llm.example.com/v1
chat_api_mode: chat_completions
chat_endpoint_path: /chat/completions
discovery_endpoint_path: /models
generate_max_retries: 5
generate_idle_timeout_sec: 300
```

新增的生成链路控制字段含义如下:

- `generate_max_retries`:额外重试次数,不含首次尝试;`<= 0` 时回退默认值 `5`,且必须 `<= 20`。
- `generate_start_timeout_sec`:写在 `config.yaml` 顶层,从发请求到收到首个有效流 payload 的最长等待窗口;`<= 0` 时回退默认值 `90`。
- `generate_idle_timeout_sec`:首包后连续没有任何新 payload 的最长空闲窗口;`<= 0` 时回退默认值 `300`。

启动时会自动把缺失的 `generate_start_timeout_sec` 规范化写回 `config.yaml`,避免磁盘配置与运行时默认值不一致。

## 不写入 `config.yaml` 的字段

以下内容不允许写入主配置文件:
Expand Down
1 change: 0 additions & 1 deletion docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
- `phase_changed`
- `progress_evaluated`
- `stop_reason_decided`
- `provider_retry`
- `permission_requested`
- `permission_resolved`
- `budget_checked`
Expand Down
39 changes: 39 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,45 @@ context:
}
}

func TestBuildSharedConfigDepsPersistsGenerateStartTimeoutDefault(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("OPENAI_API_KEY", "test-key")

configDir := filepath.Join(home, ".neocode")
if err := os.MkdirAll(configDir, 0o755); err != nil {
t.Fatalf("mkdir config dir: %v", err)
}
configPath := filepath.Join(configDir, "config.yaml")
raw := "selected_provider: openai\ncurrent_model: gpt-5.4\nshell: powershell\n"
if err := os.WriteFile(configPath, []byte(raw), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

shared, _, _, err := BuildSharedConfigDeps(context.Background(), BootstrapOptions{})
if err != nil {
t.Fatalf("BuildSharedConfigDeps() error = %v", err)
}
if shared.Config.GenerateStartTimeoutSec != config.DefaultGenerateStartTimeoutSec {
t.Fatalf(
"expected generate_start_timeout_sec=%d, got %d",
config.DefaultGenerateStartTimeoutSec,
shared.Config.GenerateStartTimeoutSec,
)
}

data, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
if !strings.Contains(string(data), "generate_start_timeout_sec: 90") {
t.Fatalf("expected config to persist generate_start_timeout_sec, got:\n%s", string(data))
}
}

func TestBuildSharedConfigDepsReturnsPreflightError(t *testing.T) {
disableBuiltinProviderAPIKeys(t)

Expand Down
45 changes: 27 additions & 18 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,32 +10,35 @@ import (
)

const (
DefaultWorkdir = "."
DefaultToolTimeoutSec = 20
DefaultWorkdir = "."
DefaultToolTimeoutSec = 20
DefaultGenerateStartTimeoutSec = 90
)

type Config struct {
Providers []ProviderConfig `yaml:"-"`
SelectedProvider string `yaml:"selected_provider"`
CurrentModel string `yaml:"current_model"`
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"`
Gateway GatewayConfig `yaml:"gateway,omitempty"`
Providers []ProviderConfig `yaml:"-"`
SelectedProvider string `yaml:"selected_provider"`
CurrentModel string `yaml:"current_model"`
Workdir string `yaml:"-"`
Shell string `yaml:"shell"`
ToolTimeoutSec int `yaml:"tool_timeout_sec,omitempty"`
GenerateStartTimeoutSec int `yaml:"generate_start_timeout_sec,omitempty"`
Runtime RuntimeConfig `yaml:"runtime,omitempty"`
Context ContextConfig `yaml:"context,omitempty"`
Tools ToolsConfig `yaml:"tools,omitempty"`
Memo MemoConfig `yaml:"memo,omitempty"`
Gateway GatewayConfig `yaml:"gateway,omitempty"`
}

// StaticDefaults 返回 config 层负责的静态默认值骨架,不包含 provider 装配和选择状态修复。
func StaticDefaults() *Config {
return &Config{
Workdir: DefaultWorkdir,
Shell: defaultShell(),
ToolTimeoutSec: DefaultToolTimeoutSec,
Runtime: defaultRuntimeConfig(),
Context: defaultContextConfig(),
Workdir: DefaultWorkdir,
Shell: defaultShell(),
ToolTimeoutSec: DefaultToolTimeoutSec,
GenerateStartTimeoutSec: DefaultGenerateStartTimeoutSec,
Runtime: defaultRuntimeConfig(),
Context: defaultContextConfig(),
Tools: ToolsConfig{
WebFetch: defaultWebFetchConfig(),
MCP: defaultMCPConfig(),
Expand Down Expand Up @@ -75,6 +78,9 @@ func (c *Config) applyStaticDefaults(defaults Config) {
if c.ToolTimeoutSec <= 0 {
c.ToolTimeoutSec = defaults.ToolTimeoutSec
}
if c.GenerateStartTimeoutSec <= 0 {
c.GenerateStartTimeoutSec = defaults.GenerateStartTimeoutSec
}
c.Runtime.ApplyDefaults(defaults.Runtime)
c.Context.ApplyDefaults(defaults.Context)
c.Tools.ApplyDefaults(defaults.Tools)
Expand All @@ -92,6 +98,9 @@ func (c *Config) ValidateSnapshot() error {
if len(c.Providers) == 0 {
return errors.New("config: providers is empty")
}
if err := validateOptionalGenerateDurationSeconds("generate_start_timeout_sec", c.GenerateStartTimeoutSec); err != nil {
return fmt.Errorf("config: %w", err)
}

seen := make(map[string]struct{}, len(c.Providers))
seenEndpoints := make(map[string]string, len(c.Providers))
Expand Down
3 changes: 3 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,9 @@ func TestLoaderLoadAndSaveRoundTrip(t *testing.T) {
if reloaded.Tools.WebFetch.MaxResponseBytes != 1024 {
t.Fatalf("expected max_response_bytes %d, got %d", 1024, reloaded.Tools.WebFetch.MaxResponseBytes)
}
if reloaded.GenerateStartTimeoutSec != DefaultGenerateStartTimeoutSec {
t.Fatalf("expected generate_start_timeout_sec %d, got %d", DefaultGenerateStartTimeoutSec, reloaded.GenerateStartTimeoutSec)
}
if len(reloaded.Tools.WebFetch.SupportedContentTypes) != 2 {
t.Fatalf("expected persisted supported content types, got %+v", reloaded.Tools.WebFetch.SupportedContentTypes)
}
Expand Down
67 changes: 39 additions & 28 deletions internal/config/context_budget_migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func MigrateContextBudgetConfigFile(path string, dryRun bool) (ContextBudgetMigr
}
result.Notes = append(result.Notes, notes...)
if !changed {
result.Reason = "未检测到 context.auto_compact"
result.Reason = "未检测到需要升级的配置字段"
return result, nil
}

Expand All @@ -81,44 +81,55 @@ func MigrateContextBudgetConfigContent(raw []byte) ([]byte, bool, []string, erro
if len(bytes.TrimSpace(raw)) == 0 {
return raw, false, nil, nil
}
if !bytes.Contains(raw, []byte("auto_compact")) {
return raw, false, nil, nil
}

var doc map[string]any
if err := yaml.Unmarshal(raw, &doc); err != nil {
return nil, false, nil, err
}
contextValue, ok := doc["context"]
if !ok {
return raw, false, nil, nil
}
contextMap, ok := migrationStringMap(contextValue)
if !ok {
return nil, false, nil, errors.New("context must be a mapping")
if doc == nil {
doc = make(map[string]any)
}

autoValue, hasAutoCompact := contextMap["auto_compact"]
if !hasAutoCompact {
return raw, false, nil, nil
}
if _, hasBudget := contextMap["budget"]; hasBudget {
return nil, false, nil, errors.New("context.auto_compact and context.budget cannot both exist")
changed := false
if _, exists := doc["generate_start_timeout_sec"]; !exists {
doc["generate_start_timeout_sec"] = DefaultGenerateStartTimeoutSec
changed = true
}

autoMap, ok := migrationStringMap(autoValue)
if !ok {
return nil, false, nil, errors.New("context.auto_compact must be a mapping")
var notes []string
contextValue, hasContext := doc["context"]
if hasContext {
contextMap, ok := migrationStringMap(contextValue)
if !ok {
return nil, false, nil, errors.New("context must be a mapping")
}

autoValue, hasAutoCompact := contextMap["auto_compact"]
if hasAutoCompact {
if _, hasBudget := contextMap["budget"]; hasBudget {
return nil, false, nil, errors.New("context.auto_compact and context.budget cannot both exist")
}

autoMap, ok := migrationStringMap(autoValue)
if !ok {
return nil, false, nil, errors.New("context.auto_compact must be a mapping")
}
budgetMap := make(map[string]any)
migrationMoveField(autoMap, budgetMap, "input_token_threshold", "prompt_budget")
migrationMoveField(autoMap, budgetMap, "reserve_tokens", "reserve_tokens")
migrationMoveField(autoMap, budgetMap, "fallback_input_token_threshold", "fallback_prompt_budget")
notes = collectContextBudgetMigrationNotes(autoMap)

delete(contextMap, "auto_compact")
contextMap["budget"] = budgetMap
doc["context"] = contextMap
changed = true
}
}
budgetMap := make(map[string]any)
migrationMoveField(autoMap, budgetMap, "input_token_threshold", "prompt_budget")
migrationMoveField(autoMap, budgetMap, "reserve_tokens", "reserve_tokens")
migrationMoveField(autoMap, budgetMap, "fallback_input_token_threshold", "fallback_prompt_budget")
notes := collectContextBudgetMigrationNotes(autoMap)

delete(contextMap, "auto_compact")
contextMap["budget"] = budgetMap
doc["context"] = contextMap
if !changed {
return raw, false, nil, nil
}

out, err := yaml.Marshal(doc)
if err != nil {
Expand Down
Loading
Loading