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: 6 additions & 0 deletions docs/context-compact.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ context:
manual_strategy: keep_recent
manual_keep_recent_messages: 10
max_summary_chars: 1200
micro_compact_disabled: false
```

- `manual_strategy`
Expand All @@ -27,6 +28,11 @@ context:
在 `keep_recent` 模式下保留最近消息数量,并按 tool call 与 tool result 的原子块整体保留。
- `max_summary_chars`
控制 compact summary 的最大字符数。
- `micro_compact_disabled`
控制是否关闭默认启用的读时 micro compact;设为 `true` 时会回退为仅 trim、不清理旧 tool result。

新增工具时,micro compact 策略不再由 `context` 层静态白名单维护,而是由 `internal/tools` 中的工具实现声明。
默认情况下,已注册工具都会参与 micro compact;只有显式声明保留历史结果的工具才会跳过旧结果清理。

## 执行链路

Expand Down
4 changes: 4 additions & 0 deletions docs/guides/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ context:
manual_strategy: keep_recent
manual_keep_recent_messages: 10
max_summary_chars: 1200
micro_compact_disabled: false
```

### 字段说明
Expand All @@ -324,5 +325,8 @@ context:
| `context.compact.manual_strategy` | string | `keep_recent` | 手动 `/compact` 策略,可选 `keep_recent` / `full_replace` |
| `context.compact.manual_keep_recent_messages` | int | `10` | `keep_recent` 模式下保留最近 N 条消息;会按 tool call 与 tool result 的原子块整体保留 |
| `context.compact.max_summary_chars` | int | `1200` | compact summary 最大字符数 |
| `context.compact.micro_compact_disabled` | bool | `false` | 是否关闭默认启用的读时 micro compact;设为 `true` 可快速回退到仅 trim、不做旧工具结果清理 |

新增工具默认会参与 micro compact;如果某个工具的历史结果必须保留,需要在 `internal/tools` 的工具实现中显式声明保留策略。

更多行为说明见 [context-compact.md](../context-compact.md)。
2 changes: 1 addition & 1 deletion internal/app/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func NewProgram(ctx context.Context) (*tea.Program, error) {
toolManager,
sessionStore,
providerRegistry,
agentcontext.NewBuilder(),
agentcontext.NewBuilderWithToolPolicies(toolRegistry),
)

tuiApp, err := tui.New(&cfg, manager, runtimeSvc, providerSelection)
Expand Down
3 changes: 3 additions & 0 deletions internal/app/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ type stubToolForBootstrap struct {
func (s stubToolForBootstrap) Name() string { return s.name }
func (s stubToolForBootstrap) Description() string { return "stub" }
func (s stubToolForBootstrap) Schema() map[string]any { return map[string]any{"type": "object"} }
func (s stubToolForBootstrap) MicroCompactPolicy() tools.MicroCompactPolicy {
return tools.MicroCompactPolicyCompact
}
func (s stubToolForBootstrap) Execute(ctx context.Context, call tools.ToolCallInput) (tools.ToolResult, error) {
return tools.ToolResult{Name: s.name, Content: s.content}, nil
}
Expand Down
10 changes: 10 additions & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -876,10 +876,14 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
if compactCfg.MaxSummaryChars != DefaultCompactMaxSummaryChars {
t.Fatalf("expected max_summary_chars=%d, got %d", DefaultCompactMaxSummaryChars, compactCfg.MaxSummaryChars)
}
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.MicroCompactDisabled = true
if err := loader.Save(context.Background(), cfg); err != nil {
t.Fatalf("Save() error = %v", err)
}
Expand All @@ -894,6 +898,9 @@ func TestCompactConfigDefaultsAndRoundTrip(t *testing.T) {
if strings.Contains(text, "manual_keep_recent_spans:") {
t.Fatalf("expected persisted config to drop legacy manual_keep_recent_spans key, got:\n%s", text)
}
if !strings.Contains(text, "micro_compact_disabled: true") {
t.Fatalf("expected persisted config to include micro_compact_disabled, got:\n%s", text)
}

reloaded, err := loader.Load(context.Background())
if err != nil {
Expand All @@ -908,6 +915,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.MicroCompactDisabled {
t.Fatalf("expected micro_compact_disabled to persist")
}
}

func TestCompactConfigValidateFailures(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ type persistedCompactConfig struct {
ManualStrategy string `yaml:"manual_strategy,omitempty"`
ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"`
MaxSummaryChars int `yaml:"max_summary_chars,omitempty"`
MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"`
}

func NewLoader(baseDir string, defaults *Config) *Loader {
Expand Down Expand Up @@ -217,6 +218,7 @@ func newPersistedContextConfig(cfg ContextConfig) persistedContextConfig {
ManualStrategy: cfg.Compact.ManualStrategy,
ManualKeepRecentMessages: cfg.Compact.ManualKeepRecentMessages,
MaxSummaryChars: cfg.Compact.MaxSummaryChars,
MicroCompactDisabled: cfg.Compact.MicroCompactDisabled,
},
}
}
Expand All @@ -228,6 +230,7 @@ func fromPersistedContextConfig(file persistedContextConfig, defaults ContextCon
ManualStrategy: strings.TrimSpace(file.Compact.ManualStrategy),
ManualKeepRecentMessages: file.Compact.ManualKeepRecentMessages,
MaxSummaryChars: file.Compact.MaxSummaryChars,
MicroCompactDisabled: file.Compact.MicroCompactDisabled,
},
}
out.Compact.ApplyDefaults(defaults.Compact)
Expand Down
1 change: 1 addition & 0 deletions internal/config/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type CompactConfig struct {
ManualStrategy string `yaml:"manual_strategy,omitempty"`
ManualKeepRecentMessages int `yaml:"manual_keep_recent_messages,omitempty"`
MaxSummaryChars int `yaml:"max_summary_chars,omitempty"`
MicroCompactDisabled bool `yaml:"micro_compact_disabled,omitempty"`
}

type WebFetchConfig struct {
Expand Down
29 changes: 24 additions & 5 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
@@ -1,23 +1,34 @@
package context

import "context"
import (
"context"

"neo-code/internal/provider"
)

// DefaultBuilder preserves the current runtime context-building behavior.
type DefaultBuilder struct {
promptSources []promptSectionSource
trimPolicy messageTrimPolicy
promptSources []promptSectionSource
trimPolicy messageTrimPolicy
microCompactPolicies MicroCompactPolicySource
}

// NewBuilder returns the default context builder implementation.
func NewBuilder() Builder {
return NewBuilderWithToolPolicies(nil)
}

// NewBuilderWithToolPolicies 返回带工具 micro compact 策略源的默认上下文构建器。
func NewBuilderWithToolPolicies(policies MicroCompactPolicySource) Builder {
systemSource := &systemStateSource{gitRunner: runGitCommand}
return &DefaultBuilder{
promptSources: []promptSectionSource{
corePromptSource{},
&projectRulesSource{},
systemSource,
},
trimPolicy: spanMessageTrimPolicy{},
trimPolicy: spanMessageTrimPolicy{},
microCompactPolicies: policies,
}
}

Expand All @@ -43,6 +54,14 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu

return BuildResult{
SystemPrompt: composeSystemPrompt(sections...),
Messages: trimPolicy.Trim(input.Messages),
Messages: applyReadTimeContextProjection(trimPolicy.Trim(input.Messages), input.Compact, b.microCompactPolicies),
}, nil
}

// applyReadTimeContextProjection 负责在 provider 请求前按开关应用只读上下文投影,避免改写原始会话消息。
func applyReadTimeContextProjection(messages []provider.Message, options CompactOptions, policies MicroCompactPolicySource) []provider.Message {
if options.DisableMicroCompact {
return cloneContextMessages(messages)
}
return microCompactMessagesWithPolicies(messages, policies)
}
198 changes: 198 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"testing"

"neo-code/internal/context/internalcompact"
"neo-code/internal/provider"
"neo-code/internal/tools"
)

type stubPromptSectionSource struct {
Expand Down Expand Up @@ -150,6 +152,202 @@ func TestDefaultBuilderBuildReturnsPromptSourceError(t *testing.T) {
}
}

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

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}},
},
}

messages := []provider.Message{
{Role: provider.RoleUser, Content: "older user"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-2", Name: "bash", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-3", Name: "webfetch", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"},
{Role: provider.RoleUser, Content: "latest explicit instruction"},
{Role: provider.RoleAssistant, Content: "current reply"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if len(got.Messages) != len(messages) {
t.Fatalf("expected builder output to keep message count, got %d want %d", len(got.Messages), len(messages))
}
if got.Messages[2].Content != microCompactClearedMessage {
t.Fatalf("expected builder output to clear older tool result, got %q", got.Messages[2].Content)
}
if got.Messages[4].Content != "recent bash result" {
t.Fatalf("expected recent tool result to stay visible, got %q", got.Messages[4].Content)
}
if got.Messages[6].Content != "latest webfetch result" {
t.Fatalf("expected latest tool result to stay visible, got %q", got.Messages[6].Content)
}
}

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

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}},
},
}

messages := []provider.Message{
{Role: provider.RoleUser, Content: "older user"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "filesystem_read_file", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-1", Content: "old read result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-2", Name: "bash", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-3", Name: "webfetch", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"},
{Role: provider.RoleUser, Content: "latest explicit instruction"},
{Role: provider.RoleAssistant, Content: "current reply"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: messages,
Compact: CompactOptions{
DisableMicroCompact: true,
},
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !reflect.DeepEqual(got.Messages, messages) {
t.Fatalf("expected messages to remain unchanged when micro compact is disabled, got %+v", got.Messages)
}
if &got.Messages[2] == &messages[2] {
t.Fatalf("expected disabled path to still clone message slice")
}
}

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

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{sections: []promptSection{{title: "Stub", content: "body"}}},
},
microCompactPolicies: stubMicroCompactPolicySource{
"custom_tool": tools.MicroCompactPolicyPreserveHistory,
},
}

messages := []provider.Message{
{Role: provider.RoleUser, Content: "older user"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "custom_tool", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-2", Name: "bash", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-3", Name: "webfetch", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"},
{Role: provider.RoleUser, Content: "latest explicit instruction"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got.Messages[2].Content != "old custom result" {
t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content)
}
}

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

builder := NewBuilderWithToolPolicies(stubMicroCompactPolicySource{
"custom_tool": tools.MicroCompactPolicyPreserveHistory,
})

messages := []provider.Message{
{Role: provider.RoleUser, Content: "older user"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-1", Name: "custom_tool", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-1", Content: "old custom result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-2", Name: "bash", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-2", Content: "recent bash result"},
{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-3", Name: "webfetch", Arguments: "{}"},
},
},
{Role: provider.RoleTool, ToolCallID: "call-3", Content: "latest webfetch result"},
{Role: provider.RoleUser, Content: "latest explicit instruction"},
}

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if got.Messages[2].Content != "old custom result" {
t.Fatalf("expected preserved tool result to remain, got %q", got.Messages[2].Content)
}
}

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

Expand Down
Loading
Loading