Skip to content
Merged
11 changes: 11 additions & 0 deletions docs/config-management-detail-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,3 +130,14 @@ custom provider 来自:
- 不在 `config` 中堆兼容旧字段的逻辑
- 不把选择修正混进快照校验
- 不把 provider/catalog/runtime 语义倒灌回 `config`

## custom provider `models` 校验约束

`~/.neocode/providers/<provider-name>/provider.yaml` 中允许通过 `models` 补齐模型元数据,用于 catalog/discovery 无法提供完整 `ContextWindow` 或 `MaxOutputTokens` 的场景。

该能力的约束是:

- `models[].id` 必须非空。
- `models[].context_window` 和 `models[].max_output_tokens` 如果显式提供,必须大于 `0`。
- 重复的模型 `id` 会在加载 custom provider 时直接失败,不保留 silently drop 的宽松行为。
- 这些元数据不会写回 `config.yaml`,只在 custom provider 文件中声明,并通过现有 catalog 合并链路参与运行时解析。
21 changes: 19 additions & 2 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Context Compact

## Auto Compact Failure Fallback

- 当 `context.auto_compact.input_token_threshold <= 0` 时,系统会尝试基于当前模型的 `ContextWindow` 自动推导阈值。
- 若当前 provider 选择无效、catalog snapshot 查询失败,或模型窗口元数据缺失,系统会直接回退到 `fallback_input_token_threshold`。
- 自动推导失败不会静默关闭 auto compact;runtime 仍会拿到一个可用的保底阈值。

本文档说明 NeoCode 中 context compact 的配置、执行链路和摘要约定。

## 概览
Expand All @@ -23,7 +29,9 @@ context:
micro_compact_disabled: false
auto_compact:
enabled: false
input_token_threshold: 100000
input_token_threshold: 0
reserve_tokens: 13000
fallback_input_token_threshold: 100000
```

- `manual_strategy`
Expand All @@ -39,7 +47,7 @@ context:
- `auto_compact.enabled`
控制是否启用基于 token 阈值的自动压缩;默认关闭。
- `auto_compact.input_token_threshold`
当会话累计输入 token 数达到此阈值时触发自动压缩;默认 100000。
当会话累计输入 token 数达到此阈值时触发自动压缩;默认 `0`(自动推导),推导失败时回退到 `fallback_input_token_threshold`(默认 `100000`)

## 自动压缩

Expand Down Expand Up @@ -154,3 +162,12 @@ compact 相关 runtime 事件包括:
- `trigger_mode`
- `transcript_id`
- `transcript_path`

## Auto Compact 阈值解析

- `context.auto_compact.input_token_threshold > 0` 时,直接使用显式手动阈值。
- `context.auto_compact.input_token_threshold <= 0` 时,系统会对当前选中的 provider/model 做自动推导。
- 自动推导公式为 `resolved_threshold = context_window - reserve_tokens`。
- `reserve_tokens` 默认 `13000`,用于给输出、tool call 和 system prompt 预留缓冲。
- 如果当前模型没有可用的 `ContextWindow`,或窗口值小于等于 `reserve_tokens`,则回退到 `fallback_input_token_threshold`。
- `fallback_input_token_threshold` 默认 `100000`,用于保证主链路在缺少模型窗口元数据时仍可稳定运行。
31 changes: 31 additions & 0 deletions docs/guides/adding-providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,34 @@ func DefaultProviders() []ProviderConfig {
```

所有内置 provider 都通过代码集中注册。模型选择器展示的候选模型由默认模型、动态发现结果和本地缓存共同组成。

## custom provider 模型元数据补齐

对于复用 `openaicompat` 驱动的 custom provider,如果上游 `GET /models` 不能返回可靠的上下文窗口信息,可以在:

```text
~/.neocode/providers/<provider-name>/provider.yaml
```

中显式声明 `models`:

```yaml
name: company-gateway
driver: openaicompat
api_key_env: COMPANY_GATEWAY_API_KEY
models:
- id: deepseek-coder
name: DeepSeek Coder
context_window: 131072
max_output_tokens: 8192
openai_compatible:
base_url: https://llm.example.com/v1
api_style: chat_completions
```

约束如下:

- `models[].id` 必须非空。
- `models[].context_window` 和 `models[].max_output_tokens` 如果显式配置,必须大于 `0`。
- 同一个 `provider.yaml` 中重复的模型 `id` 会在加载阶段直接报错。
- 这些元数据会进入统一的 model catalog 合并链路,优先级仍为“配置模型元数据优先于 discovery/default”。
13 changes: 12 additions & 1 deletion docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ context:
micro_compact_disabled: false
auto_compact:
enabled: false
input_token_threshold: 100000
input_token_threshold: 0
reserve_tokens: 13000
fallback_input_token_threshold: 100000
```

### 基础字段
Expand All @@ -81,6 +83,8 @@ context:
| `context.compact.micro_compact_disabled` | 是否关闭默认启用的 micro compact |
| `context.auto_compact.enabled` | 是否启用自动压缩 |
| `context.auto_compact.input_token_threshold` | 自动压缩输入 token 阈值 |
| `context.auto_compact.reserve_tokens` | 自动阈值推导时预留 token 缓冲(`resolved_threshold = context_window - reserve_tokens`) |
| `context.auto_compact.fallback_input_token_threshold` | 自动推导失败时使用的保底阈值 |

### `runtime` 字段

Expand Down Expand Up @@ -142,6 +146,13 @@ openai_compatible:
api_style: chat_completions
```

## Auto Compact 失败与校验补充

- 当 `context.auto_compact.input_token_threshold <= 0` 时,如果当前 provider 选择无效、catalog snapshot 查询失败、模型缺少可用的 `ContextWindow`,或 `ContextWindow <= reserve_tokens`,系统会回退到 `fallback_input_token_threshold`,不会静默关闭 auto compact。
- `~/.neocode/providers/<provider-name>/provider.yaml` 中的 `models[].id` 必须非空。
- `models[].context_window` 和 `models[].max_output_tokens` 如果显式配置,必须大于 `0`。
- `models` 中重复的模型 `id` 会在加载 `provider.yaml` 时直接报错。

文件路径:

```text
Expand Down
15 changes: 15 additions & 0 deletions internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,15 @@ func BuildRuntime(ctx context.Context, opts BootstrapOptions) (RuntimeBundle, er
contextBuilder,
)
runtimeSvc.SetSkillsRegistry(buildSkillsRegistry(ctx, loader.BaseDir()))
runtimeSvc.SetAutoCompactThresholdResolver(runtimeAutoCompactThresholdResolverFunc(
func(ctx context.Context, cfg config.Config) (int, error) {
resolution, err := configstate.ResolveAutoCompactThreshold(ctx, cfg, modelCatalogs)
if err != nil {
return 0, err
}
return resolution.Threshold, nil
},
))

// 注入记忆提取钩子:当 AutoExtract 启用且 memoSvc 可用时,ReAct 循环完成后异步提取记忆。
if memoSvc != nil && cfg.Memo.AutoExtract {
Expand Down Expand Up @@ -306,3 +315,9 @@ type textGenAdapter func(ctx context.Context, prompt string, msgs []providertype
func (f textGenAdapter) Generate(ctx context.Context, prompt string, msgs []providertypes.Message) (string, error) {
return f(ctx, prompt, msgs)
}

type runtimeAutoCompactThresholdResolverFunc func(ctx context.Context, cfg config.Config) (int, error)

func (f runtimeAutoCompactThresholdResolverFunc) ResolveAutoCompactThreshold(ctx context.Context, cfg config.Config) (int, error) {
return f(ctx, cfg)
}
133 changes: 104 additions & 29 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1242,6 +1242,14 @@ func TestAutoCompactConfigDefaults(t *testing.T) {
t.Fatalf("expected input_token_threshold=%d, got %d",
DefaultAutoCompactInputTokenThreshold, cfg.Context.AutoCompact.InputTokenThreshold)
}
if cfg.Context.AutoCompact.ReserveTokens != DefaultAutoCompactReserveTokens {
t.Fatalf("expected reserve_tokens=%d, got %d",
DefaultAutoCompactReserveTokens, cfg.Context.AutoCompact.ReserveTokens)
}
if cfg.Context.AutoCompact.FallbackInputTokenThreshold != DefaultAutoCompactFallbackInputTokenThreshold {
t.Fatalf("expected fallback_input_token_threshold=%d, got %d",
DefaultAutoCompactFallbackInputTokenThreshold, cfg.Context.AutoCompact.FallbackInputTokenThreshold)
}

if cfg.Context.AutoCompact.Enabled != false {
t.Fatalf("expected enabled=false, got %v", cfg.Context.AutoCompact.Enabled)
Expand All @@ -1253,38 +1261,55 @@ func TestAutoCompactConfigApplyDefaults(t *testing.T) {

cfg := AutoCompactConfig{}
defaults := AutoCompactConfig{
InputTokenThreshold: 50000,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
}

cfg.ApplyDefaults(defaults)

if cfg.InputTokenThreshold != 50000 {
t.Fatalf("expected threshold=50000, got %d", cfg.InputTokenThreshold)
if cfg.InputTokenThreshold != 0 {
t.Fatalf("expected threshold to remain implicit 0, got %d", cfg.InputTokenThreshold)
}
if cfg.ReserveTokens != 13000 {
t.Fatalf("expected reserve_tokens=13000, got %d", cfg.ReserveTokens)
}
if cfg.FallbackInputTokenThreshold != 100000 {
t.Fatalf("expected fallback_input_token_threshold=100000, got %d", cfg.FallbackInputTokenThreshold)
}
}

func TestAutoCompactConfigApplyDefaultsPreservesExplicit(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
InputTokenThreshold: 200000,
InputTokenThreshold: 200000,
ReserveTokens: 5000,
FallbackInputTokenThreshold: 80000,
}
defaults := AutoCompactConfig{
InputTokenThreshold: 50000,
InputTokenThreshold: 50000,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
}

cfg.ApplyDefaults(defaults)

if cfg.InputTokenThreshold != 200000 {
t.Fatalf("expected explicit threshold=200000 to be preserved, got %d", cfg.InputTokenThreshold)
}
if cfg.ReserveTokens != 5000 {
t.Fatalf("expected explicit reserve_tokens=5000 to be preserved, got %d", cfg.ReserveTokens)
}
if cfg.FallbackInputTokenThreshold != 80000 {
t.Fatalf("expected explicit fallback_input_token_threshold=80000 to be preserved, got %d", cfg.FallbackInputTokenThreshold)
}
}

func TestAutoCompactConfigApplyDefaultsNilReceiver(t *testing.T) {
t.Parallel()

var cfg *AutoCompactConfig
cfg.ApplyDefaults(AutoCompactConfig{InputTokenThreshold: 50000})
cfg.ApplyDefaults(AutoCompactConfig{ReserveTokens: 13000, FallbackInputTokenThreshold: 100000})
}

func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) {
Expand All @@ -1293,7 +1318,8 @@ func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) {
cfg := ContextConfig{}
cfg.ApplyDefaults(ContextConfig{
AutoCompact: AutoCompactConfig{
InputTokenThreshold: 50000,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
},
Compact: CompactConfig{
ManualStrategy: CompactManualStrategyKeepRecent,
Expand All @@ -1303,34 +1329,41 @@ func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) {
},
})

if cfg.AutoCompact.InputTokenThreshold != 50000 {
t.Fatalf("expected auto compact threshold=50000, got %d", cfg.AutoCompact.InputTokenThreshold)
if cfg.AutoCompact.InputTokenThreshold != 0 {
t.Fatalf("expected auto compact threshold to remain implicit 0, got %d", cfg.AutoCompact.InputTokenThreshold)
}
if cfg.AutoCompact.ReserveTokens != 13000 {
t.Fatalf("expected reserve_tokens=13000, got %d", cfg.AutoCompact.ReserveTokens)
}
if cfg.AutoCompact.FallbackInputTokenThreshold != 100000 {
t.Fatalf("expected fallback_input_token_threshold=100000, got %d", cfg.AutoCompact.FallbackInputTokenThreshold)
}
}

func TestAutoCompactConfigValidateEnabledWithoutThreshold(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 0,
Enabled: true,
InputTokenThreshold: 0,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
}

err := cfg.Validate()
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "input_token_threshold") {
t.Fatalf("expected error about input_token_threshold, got %v", err)
if err != nil {
t.Fatalf("expected validation to allow implicit threshold, got %v", err)
}
}

func TestAutoCompactConfigValidateDisabledWithoutThreshold(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: false,
InputTokenThreshold: 0,
Enabled: false,
InputTokenThreshold: 0,
ReserveTokens: 0,
FallbackInputTokenThreshold: 0,
}

err := cfg.Validate()
Expand All @@ -1343,8 +1376,10 @@ func TestAutoCompactConfigValidateEnabledWithThreshold(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 50000,
Enabled: true,
InputTokenThreshold: 50000,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
}

err := cfg.Validate()
Expand All @@ -1353,12 +1388,46 @@ func TestAutoCompactConfigValidateEnabledWithThreshold(t *testing.T) {
}
}

func TestAutoCompactConfigValidateRejectsNonPositiveReserveTokens(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 0,
ReserveTokens: 0,
FallbackInputTokenThreshold: 100000,
}

err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "reserve_tokens") {
t.Fatalf("expected reserve_tokens validation error, got %v", err)
}
}

func TestAutoCompactConfigValidateRejectsNonPositiveFallbackThreshold(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 0,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 0,
}

err := cfg.Validate()
if err == nil || !strings.Contains(err.Error(), "fallback_input_token_threshold") {
t.Fatalf("expected fallback_input_token_threshold validation error, got %v", err)
}
}

func TestAutoCompactConfigClone(t *testing.T) {
t.Parallel()

cfg := AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 75000,
Enabled: true,
InputTokenThreshold: 75000,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
}

cloned := cfg.Clone()
Expand All @@ -1370,6 +1439,13 @@ func TestAutoCompactConfigClone(t *testing.T) {
t.Fatalf("expected threshold=%d to be cloned, got %d",
cfg.InputTokenThreshold, cloned.InputTokenThreshold)
}
if cfg.ReserveTokens != cloned.ReserveTokens {
t.Fatalf("expected reserve_tokens=%d to be cloned, got %d", cfg.ReserveTokens, cloned.ReserveTokens)
}
if cfg.FallbackInputTokenThreshold != cloned.FallbackInputTokenThreshold {
t.Fatalf("expected fallback_input_token_threshold=%d to be cloned, got %d",
cfg.FallbackInputTokenThreshold, cloned.FallbackInputTokenThreshold)
}

cloned.InputTokenThreshold = 100000
if cfg.InputTokenThreshold == cloned.InputTokenThreshold {
Expand All @@ -1382,8 +1458,10 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) {

ctx := ContextConfig{
AutoCompact: AutoCompactConfig{
Enabled: true,
InputTokenThreshold: 0,
Enabled: true,
InputTokenThreshold: 0,
ReserveTokens: 13000,
FallbackInputTokenThreshold: 100000,
},
Compact: CompactConfig{
ManualStrategy: CompactManualStrategyKeepRecent,
Expand All @@ -1394,11 +1472,8 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) {
}

err := ctx.Validate()
if err == nil {
t.Fatalf("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "auto_compact") {
t.Fatalf("expected error to contain 'auto_compact', got %v", err)
if err != nil {
t.Fatalf("expected context validation to allow implicit threshold, got %v", err)
}
}

Expand Down
Loading
Loading