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
13 changes: 11 additions & 2 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

## 概览

- runtime 已接入手动 compact、基于 token 阈值的自动 compact,以及 provider 上下文过长后的 `reactive` compact 自动恢复。
- `internal/context/compact` 支持 `manual`、`auto` 与 `reactive` 三种 mode。
- runtime 已接入手动 compact、基于 token 阈值的自动 compactprovider 上下文过长后的 `reactive` compact 自动恢复,以及命中最大轮数前的 `loop_limit` continuation checkpoint
- `internal/context/compact` 支持 `manual`、`auto`、`reactive` 与 `loop_limit` 四种 mode。
- 用户通过 `/compact` 对当前会话执行一次上下文压缩。
- compact 前会先写入完整 transcript,随后生成并校验新的 durable `TaskState` 与 display summary,再回写会话消息。

Expand All @@ -18,6 +18,7 @@ context:
compact:
manual_strategy: keep_recent
manual_keep_recent_messages: 10
read_time_max_message_spans: 24
max_summary_chars: 1200
micro_compact_disabled: false
auto_compact:
Expand All @@ -29,6 +30,8 @@ context:
控制手动 compact 的策略,支持 `keep_recent` 和 `full_replace`。
- `manual_keep_recent_messages`
在 `keep_recent` 模式下保留最近消息数量,并按 tool call 与 tool result 的原子块整体保留。
- `read_time_max_message_spans`
控制 `context.Builder` 读时 trim 可保留的 message span 上限;该值越大,普通“继续”续跑时越不容易在未触发 compact 前丢掉较早的文件读取结果。
- `max_summary_chars`
控制 compact summary 的最大字符数。
- `micro_compact_disabled`
Expand Down Expand Up @@ -77,6 +80,12 @@ context:
3. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=reactive` 区分来源。
4. 每次 `Run()` 最多只执行一次 reactive 重试,避免无限循环。

当 runtime 命中 `max_loops` 停止条件时,还会在返回最终错误前 best-effort 触发一次 `compact.Run(mode=loop_limit)` 作为 continuation checkpoint:

1. 成功时回写 compact 后的消息、durable `TaskState` 与重置后的 token 计数。
2. 继续复用 `compact_start`、`compact_done`、`compact_error` 事件,并通过 `trigger_mode=loop_limit` 标记来源。
3. 无论 compact 成功、失败或 no-op,本次 run 仍会结束;区别只在于后续“继续”能否优先复用 durable checkpoint。

## 生成协议

compact generator 必须只返回一个 JSON 对象,顶层固定包含:
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ context:
compact:
manual_strategy: keep_recent
manual_keep_recent_messages: 10
read_time_max_message_spans: 24
max_summary_chars: 1200
micro_compact_disabled: false
auto_compact:
Expand All @@ -75,6 +76,7 @@ context:
|------|------|
| `context.compact.manual_strategy` | `/compact` 手动压缩策略,支持 `keep_recent` / `full_replace` |
| `context.compact.manual_keep_recent_messages` | `keep_recent` 策略下保留的最近消息数 |
| `context.compact.read_time_max_message_spans` | context 读时保留的 message span 上限,用于降低“继续”时较早文件读取结果被过早裁掉的风险 |
| `context.compact.max_summary_chars` | compact summary 最大字符数 |
| `context.compact.micro_compact_disabled` | 是否关闭默认启用的 micro compact |
| `context.auto_compact.enabled` | 是否启用自动压缩 |
Expand Down
6 changes: 6 additions & 0 deletions docs/runtime-provider-event-flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
- `compact_done`
- `compact_error`

这三类 compact 事件同时用于 `manual`、`auto`、`reactive` 与 `loop_limit` 四种来源,调用方可通过 payload 中的 `trigger_mode` 区分。

## ReAct 主循环

1. 加载目标会话或创建新会话。
Expand All @@ -36,6 +38,10 @@
11. 如果最终 assistant 回复后没有后续工具调用,则在 runtime 收口处安排一次后台 memo 自动提取。
12. 如果仍需继续推理,则进入下一轮;否则结束。

补充说明:
- 当下一轮开始前已命中 `max_loops` 上限时,runtime 会先尝试一次 `loop_limit` compact checkpoint,再发出最终 `error` 事件结束本次 run。
- 该 checkpoint 成功时会先发出 `compact_start -> compact_done`,并把 session 消息、`TaskState` 与累计 token 重置后的状态持久化;失败时会发出 `compact_error`,随后仍以最大轮数错误结束。

### Memo 自动提取调度

- 自动提取只在最终 assistant 回复完成且当前轮没有后续工具调用时调度。
Expand Down
31 changes: 31 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1082,13 +1082,21 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars {
t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars)
}
if compactCfg.ReadTimeMaxMessageSpans != DefaultCompactReadTimeMaxMessageSpans {
t.Fatalf(
"expected read_time_max_message_spans=%d, got %d",
DefaultCompactReadTimeMaxMessageSpans,
compactCfg.ReadTimeMaxMessageSpans,
)
}
if compactCfg.MicroCompactDisabled {
t.Fatalf("expected micro compact to be enabled by default")
}

cfg.Context.Compact.ManualStrategy = CompactManualStrategyFullReplace
cfg.Context.Compact.ManualKeepRecentMessages = 2
cfg.Context.Compact.MaxSummaryChars = 900
cfg.Context.Compact.ReadTimeMaxMessageSpans = 30
cfg.Context.Compact.MicroCompactDisabled = true
if err := loader.Save(context.Background(), cfg); err != nil {
t.Fatalf("Save() error = %v", err)
Expand All @@ -1107,6 +1115,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
if !strings.Contains(text, "micro_compact_disabled: true") {
t.Fatalf("expected persisted config to include micro_compact_disabled, got:\n%s", text)
}
if !strings.Contains(text, "read_time_max_message_spans: 30") {
t.Fatalf("expected persisted config to include read_time_max_message_spans, got:\n%s", text)
}

reloaded, err := loader.Load(context.Background())
if err != nil {
Expand All @@ -1121,6 +1132,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
if reloaded.Context.Compact.MaxSummaryChars != 900 {
t.Fatalf("expected max_summary_chars=900, got %d", reloaded.Context.Compact.MaxSummaryChars)
}
if reloaded.Context.Compact.ReadTimeMaxMessageSpans != 30 {
t.Fatalf("expected read_time_max_message_spans=30, got %d", reloaded.Context.Compact.ReadTimeMaxMessageSpans)
}
if !reloaded.Context.Compact.MicroCompactDisabled {
t.Fatalf("expected micro_compact_disabled to persist")
}
Expand All @@ -1138,6 +1152,7 @@ func TestCompactConfigValidateFailures(t *testing.T) {
ManualStrategy: "invalid",
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
expectErr: "manual_strategy",
},
Expand All @@ -1147,6 +1162,7 @@ func TestCompactConfigValidateFailures(t *testing.T) {
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 0,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
expectErr: "manual_keep_recent_messages",
},
Expand All @@ -1156,9 +1172,20 @@ func TestCompactConfigValidateFailures(t *testing.T) {
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 0,
ReadTimeMaxMessageSpans: 24,
},
expectErr: "max_summary_chars",
},
{
name: "invalid read time max spans",
compact: CompactConfig{
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 0,
},
expectErr: "read_time_max_message_spans",
},
}

for _, tt := range tests {
Expand All @@ -1177,6 +1204,7 @@ func TestCompactConfigValidateSupportsFullReplace(t *testing.T) {
ManualStrategy: CompactManualStrategyFullReplace,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
}).Validate()
if err != nil {
t.Fatalf("expected full_replace strategy to validate, got %v", err)
Expand Down Expand Up @@ -1261,6 +1289,7 @@ func TestContextConfigApplyDefaultsPropagatesAutoCompactDefaults(t *testing.T) {
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
})

Expand Down Expand Up @@ -1350,6 +1379,7 @@ func TestAutoCompactConfigContextConfigValidate(t *testing.T) {
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
}

Expand Down Expand Up @@ -1560,6 +1590,7 @@ func TestValidateSnapshotPropagatesCompactError(t *testing.T) {
ManualStrategy: "invalid_strategy",
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
},
}
Expand Down
9 changes: 9 additions & 0 deletions internal/config/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const (
DefaultCompactMaxSummaryChars = 1200
DefaultAutoCompactInputTokenThreshold = 100000
DefaultMicroCompactRetainedToolSpans = 2
DefaultCompactReadTimeMaxMessageSpans = 24

CompactManualStrategyKeepRecent = "keep_recent"
CompactManualStrategyFullReplace = "full_replace"
Expand All @@ -27,6 +28,7 @@ type CompactConfig struct {
MaxSummaryChars int `yaml:"max_summary_chars,omitempty"`
MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"`
MicroCompactRetainedToolSpans int `yaml:"micro_compact_retained_tool_spans,omitempty"`
ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"`
MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"`
}

Expand Down Expand Up @@ -57,6 +59,7 @@ func defaultCompactConfig() CompactConfig {
ManualKeepRecentMessages: DefaultCompactManualKeepRecentMessages,
MaxSummaryChars: DefaultCompactMaxSummaryChars,
MicroCompactRetainedToolSpans: DefaultMicroCompactRetainedToolSpans,
ReadTimeMaxMessageSpans: DefaultCompactReadTimeMaxMessageSpans,
}
}

Expand Down Expand Up @@ -106,6 +109,9 @@ func (c *CompactConfig) ApplyDefaults(defaults CompactConfig) {
if c.MicroCompactRetainedToolSpans <= 0 {
c.MicroCompactRetainedToolSpans = defaults.MicroCompactRetainedToolSpans
}
if c.ReadTimeMaxMessageSpans <= 0 {
c.ReadTimeMaxMessageSpans = defaults.ReadTimeMaxMessageSpans
}
}

// ApplyDefaults 为 auto_compact 配置填充缺省阈值。
Expand Down Expand Up @@ -137,6 +143,9 @@ func (c CompactConfig) Validate() error {
if c.MaxSummaryChars <= 0 {
return errors.New("max_summary_chars must be greater than 0")
}
if c.ReadTimeMaxMessageSpans <= 0 {
return errors.New("read_time_max_message_spans must be greater than 0")
}

switch strings.ToLower(strings.TrimSpace(c.ManualStrategy)) {
case CompactManualStrategyKeepRecent, CompactManualStrategyFullReplace:
Expand Down
6 changes: 6 additions & 0 deletions internal/config/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ func TestContextConfigCloneIndependence(t *testing.T) {
ManualStrategy: CompactManualStrategyKeepRecent,
ManualKeepRecentMessages: 10,
MaxSummaryChars: 1200,
ReadTimeMaxMessageSpans: 24,
},
AutoCompact: AutoCompactConfig{
Enabled: true,
Expand Down Expand Up @@ -48,6 +49,7 @@ func TestCompactConfigCloneValueSemantics(t *testing.T) {
ManualKeepRecentMessages: 5,
MaxSummaryChars: 800,
MicroCompactDisabled: true,
ReadTimeMaxMessageSpans: 24,
}
cloned := original.Clone()
if original != cloned {
Expand Down Expand Up @@ -98,6 +100,7 @@ func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) {
ManualStrategy: CompactManualStrategyFullReplace,
ManualKeepRecentMessages: 15,
MaxSummaryChars: 2000,
ReadTimeMaxMessageSpans: 24,
}
cfg.ApplyDefaults(defaults)
if cfg.ManualStrategy != CompactManualStrategyFullReplace {
Expand All @@ -109,4 +112,7 @@ func TestCompactConfigApplyDefaultsAllZeroValues(t *testing.T) {
if cfg.MaxSummaryChars != 2000 {
t.Fatalf("expected chars=2000, got %d", cfg.MaxSummaryChars)
}
if cfg.ReadTimeMaxMessageSpans != 24 {
t.Fatalf("expected read-time spans=24, got %d", cfg.ReadTimeMaxMessageSpans)
}
}
3 changes: 3 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type persistedCompactConfig struct {
MaxSummaryChars int `yaml:"max_summary_chars,omitempty"`
MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"`
MicroCompactRetainedToolSpans int `yaml:"micro_compact_retained_tool_spans,omitempty"`
ReadTimeMaxMessageSpans int `yaml:"read_time_max_message_spans,omitempty"`
MaxArchivedPromptChars int `yaml:"max_archived_prompt_chars,omitempty"`
}

Expand Down Expand Up @@ -244,6 +245,7 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig {
MaxSummaryChars: cfg.Compact.MaxSummaryChars,
MicroCompactDisabled: cfg.Compact.MicroCompactDisabled,
MicroCompactRetainedToolSpans: cfg.Compact.MicroCompactRetainedToolSpans,
ReadTimeMaxMessageSpans: cfg.Compact.ReadTimeMaxMessageSpans,
MaxArchivedPromptChars: cfg.Compact.MaxArchivedPromptChars,
},
AutoCompact: persistedAutoCompactConfig{
Expand All @@ -262,6 +264,7 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon
MaxSummaryChars: file.Compact.MaxSummaryChars,
MicroCompactDisabled: file.Compact.MicroCompactDisabled,
MicroCompactRetainedToolSpans: file.Compact.MicroCompactRetainedToolSpans,
ReadTimeMaxMessageSpans: file.Compact.ReadTimeMaxMessageSpans,
MaxArchivedPromptChars: file.Compact.MaxArchivedPromptChars,
},
AutoCompact: AutoCompactConfig{
Expand Down
2 changes: 1 addition & 1 deletion internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu

return BuildResult{
SystemPrompt: composeSystemPrompt(sections...),
Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.TaskState, input.Compact, b.microCompactPolicies),
Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages, input.Compact), input.TaskState, input.Compact, b.microCompactPolicies),
AutoCompactSuggested: shouldAutoCompact,
}, nil
}
Expand Down
17 changes: 12 additions & 5 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import (
"strings"
"testing"

"neo-code/internal/config"
"neo-code/internal/context/internalcompact"
providertypes "neo-code/internal/provider/types"
agentsession "neo-code/internal/session"
"neo-code/internal/tools"
)

const maxRetainedMessageSpans = config.DefaultCompactReadTimeMaxMessageSpans

type stubPromptSectionSource struct {
sections []promptSection
err error
Expand Down Expand Up @@ -440,7 +443,7 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) {
providertypes.Message{Role: "user", Content: "latest"},
)

trimmed := trimMessages(messages)
trimmed := trimMessages(messages, maxRetainedMessageSpans)
if len(trimmed) > len(messages) {
t.Fatalf("trimmed messages should not grow")
}
Expand All @@ -463,6 +466,8 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) {
func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) {
t.Parallel()

const retainedSpans = 10

messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+5)
for i := 0; i < 2; i++ {
messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("old-%d", i)})
Expand All @@ -482,7 +487,7 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) {
providertypes.Message{Role: providertypes.RoleAssistant, Content: "follow-up-11"},
)

trimmed := trimMessages(messages)
trimmed := trimMessages(messages, retainedSpans)
if trimmed[0].Role != providertypes.RoleUser || trimmed[0].Content != "latest explicit instruction" {
t.Fatalf("expected protected tail to keep latest explicit user instruction, got %+v", trimmed[0])
}
Expand All @@ -494,6 +499,8 @@ func TestTrimMessagesProtectsLatestExplicitUserInstructionTail(t *testing.T) {
func TestTrimMessagesUsesSharedSpanModel(t *testing.T) {
t.Parallel()

const retainedSpans = 10

messages := make([]providertypes.Message, 0, maxRetainedMessageSpans+6)
for i := 0; i < 3; i++ {
messages = append(messages, providertypes.Message{Role: providertypes.RoleUser, Content: fmt.Sprintf("u-%d", i)})
Expand All @@ -518,9 +525,9 @@ func TestTrimMessagesUsesSharedSpanModel(t *testing.T) {
)

spans := internalcompact.BuildMessageSpans(messages)
trimmed := trimMessages(messages)
trimmed := trimMessages(messages, retainedSpans)

start := spans[len(spans)-maxRetainedMessageSpans].Start
start := spans[len(spans)-retainedSpans].Start
if len(trimmed) == 0 || trimmed[0].Content != messages[start].Content {
t.Fatalf("expected trim to start from shared span boundary %d, got %+v", start, trimmed)
}
Expand Down Expand Up @@ -613,7 +620,7 @@ func TestTrimMessagesBoundaries(t *testing.T) {
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
trimmed := trimMessages(tt.input)
trimmed := trimMessages(tt.input, maxRetainedMessageSpans)
if len(trimmed) != tt.wantLen {
t.Fatalf("expected len %d, got %d", tt.wantLen, len(trimmed))
}
Expand Down
2 changes: 1 addition & 1 deletion internal/context/compact/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ type compactionPlanner struct{}

// Plan 根据 mode 与配置返回摘要前的裁剪规划结果。
func (compactionPlanner) Plan(mode Mode, messages []providertypes.Message, cfg config.CompactConfig) (compactionPlan, error) {
if mode == ModeReactive {
if mode == ModeReactive || mode == ModeLoopLimit {
return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil
}

Expand Down
4 changes: 3 additions & 1 deletion internal/context/compact/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const (
ModeAuto Mode = "auto"
// ModeReactive runs the provider-error-triggered compact flow.
ModeReactive Mode = "reactive"
// ModeLoopLimit runs the continuation checkpoint flow triggered before loop-budget exit.
ModeLoopLimit Mode = "loop_limit"
)

// ErrorMode classifies compact result errors.
Expand Down Expand Up @@ -127,7 +129,7 @@ func (s *Service) Run(ctx context.Context, input Input) (Result, error) {
return Result{}, err
}

if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive {
if input.Mode != ModeManual && input.Mode != ModeAuto && input.Mode != ModeReactive && input.Mode != ModeLoopLimit {
return Result{}, fmt.Errorf("compact: unsupported mode %q", input.Mode)
}

Expand Down
Loading
Loading