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
33 changes: 20 additions & 13 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@ import "context"

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

// NewBuilder returns the default context builder implementation.
func NewBuilder() Builder {
systemSource := &systemStateSource{gitRunner: runGitCommand}
return &DefaultBuilder{
gitRunner: runGitCommand,
promptSources: []promptSectionSource{
corePromptSource{},
&projectRulesSource{},
systemSource,
},
trimPolicy: spanMessageTrimPolicy{},
}
}

Expand All @@ -20,22 +27,22 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu
return BuildResult{}, err
}

rules, err := loadProjectRules(ctx, input.Metadata.Workdir)
if err != nil {
return BuildResult{}, err
sections := make([]promptSection, 0, len(b.promptSources)+1)
for _, source := range b.promptSources {
sourceSections, err := source.Sections(ctx, input)
if err != nil {
return BuildResult{}, err
}
sections = append(sections, sourceSections...)
}

systemState, err := collectSystemState(ctx, input.Metadata, b.gitRunner)
if err != nil {
return BuildResult{}, err
trimPolicy := b.trimPolicy
if trimPolicy == nil {
trimPolicy = spanMessageTrimPolicy{}
}

sections := append([]promptSection{}, defaultSystemPromptSections()...)
sections = append(sections, renderProjectRulesSection(rules))
sections = append(sections, renderSystemStateSection(systemState))

return BuildResult{
SystemPrompt: composeSystemPrompt(sections...),
Messages: trimMessages(input.Messages),
Messages: trimPolicy.Trim(input.Messages),
}, nil
}
126 changes: 126 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,22 @@ import (
"strings"
"testing"

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

type stubPromptSectionSource struct {
sections []promptSection
err error
}

func (s stubPromptSectionSource) Sections(ctx stdcontext.Context, input BuildInput) ([]promptSection, error) {
if s.err != nil {
return nil, s.err
}
return append([]promptSection(nil), s.sections...), nil
}

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

Expand Down Expand Up @@ -93,6 +106,50 @@ func TestDefaultBuilderBuildComposesPromptSectionsInOrder(t *testing.T) {
}
}

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

messages := make([]provider.Message, 0, maxRetainedMessageSpans+2)
for i := 0; i < maxRetainedMessageSpans+2; i++ {
messages = append(messages, provider.Message{
Role: provider.RoleUser,
Content: fmt.Sprintf("u-%d", i),
})
}

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

got, err := builder.Build(stdcontext.Background(), BuildInput{Messages: messages})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if len(got.Messages) != maxRetainedMessageSpans {
t.Fatalf("expected %d retained messages, got %d", maxRetainedMessageSpans, len(got.Messages))
}
if got.Messages[0].Content != "u-2" {
t.Fatalf("expected oldest messages to be trimmed, got first message %+v", got.Messages[0])
}
}

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

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{err: fmt.Errorf("source failed")},
},
}

_, err := builder.Build(stdcontext.Background(), BuildInput{})
if err == nil || !strings.Contains(err.Error(), "source failed") {
t.Fatalf("expected source error, got %v", err)
}
}

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

Expand Down Expand Up @@ -132,6 +189,75 @@ func TestTrimMessagesPreservesToolPairs(t *testing.T) {
}
}

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

messages := make([]provider.Message, 0, maxRetainedMessageSpans+5)
for i := 0; i < 2; i++ {
messages = append(messages, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("old-%d", i)})
}
messages = append(messages,
provider.Message{Role: provider.RoleUser, Content: "latest explicit instruction"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-1"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-2"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-3"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-4"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-5"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-6"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-7"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-8"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-9"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-10"},
provider.Message{Role: provider.RoleAssistant, Content: "follow-up-11"},
)

trimmed := trimMessages(messages)
if trimmed[0].Role != provider.RoleUser || trimmed[0].Content != "latest explicit instruction" {
t.Fatalf("expected protected tail to keep latest explicit user instruction, got %+v", trimmed[0])
}
if len(trimmed) != 12 {
t.Fatalf("expected protected tail to keep latest instruction and full assistant tail, got %d messages", len(trimmed))
}
}

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

messages := make([]provider.Message, 0, maxRetainedMessageSpans+6)
for i := 0; i < 3; i++ {
messages = append(messages, provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("u-%d", i)})
}
messages = append(messages,
provider.Message{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{
{ID: "call-2", Name: "filesystem_read_file", Arguments: "{}"},
},
},
provider.Message{Role: provider.RoleTool, ToolCallID: "call-2", Content: "tool-result"},
provider.Message{Role: provider.RoleAssistant, Content: "after tool"},
provider.Message{Role: provider.RoleUser, Content: "u-4"},
provider.Message{Role: provider.RoleAssistant, Content: "a-5"},
provider.Message{Role: provider.RoleUser, Content: "u-6"},
provider.Message{Role: provider.RoleAssistant, Content: "a-7"},
provider.Message{Role: provider.RoleUser, Content: "u-8"},
provider.Message{Role: provider.RoleAssistant, Content: "a-9"},
provider.Message{Role: provider.RoleUser, Content: "u-10"},
provider.Message{Role: provider.RoleAssistant, Content: "a-11"},
)

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

start := spans[len(spans)-maxRetainedMessageSpans].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)
}
if trimmed[0].Role != provider.RoleAssistant || len(trimmed[0].ToolCalls) != 1 {
t.Fatalf("expected trim to keep whole tool block at shared boundary, got %+v", trimmed[0])
}
}

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

Expand Down
37 changes: 37 additions & 0 deletions internal/context/compact/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package compact

import (
"unicode/utf8"

"neo-code/internal/provider"
)

// cloneMessages 深拷贝消息切片,避免后续规划或摘要阶段共享底层数据。
func cloneMessages(messages []provider.Message) []provider.Message {
if len(messages) == 0 {
return nil
}
out := make([]provider.Message, 0, len(messages))
for _, message := range messages {
next := message
next.ToolCalls = append([]provider.ToolCall(nil), message.ToolCalls...)
out = append(out, next)
}
return out
}

// countMessageChars 以 rune 数量统计消息体积,用于 compact 前后指标计算。
func countMessageChars(messages []provider.Message) int {
total := 0
for _, message := range messages {
total += utf8.RuneCountInString(message.Role)
total += utf8.RuneCountInString(message.Content)
total += utf8.RuneCountInString(message.ToolCallID)
for _, call := range message.ToolCalls {
total += utf8.RuneCountInString(call.ID)
total += utf8.RuneCountInString(call.Name)
total += utf8.RuneCountInString(call.Arguments)
}
}
return total
}
89 changes: 89 additions & 0 deletions internal/context/compact/planner.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package compact

import (
"fmt"
"strings"

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

// compactionPlan 描述一次 compact 在摘要生成前的归档与保留结果。
type compactionPlan struct {
Archived []provider.Message
Retained []provider.Message
ArchivedMessageCount int
Applied bool
}

// compactionPlanner 负责根据策略规划 archived 和 retained 的消息边界。
type compactionPlanner struct{}

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

switch strings.ToLower(strings.TrimSpace(cfg.ManualStrategy)) {
case config.CompactManualStrategyKeepRecent:
return planKeepRecent(messages, cfg.ManualKeepRecentMessages), nil
case config.CompactManualStrategyFullReplace:
return planFullReplace(messages), nil
default:
return compactionPlan{}, fmt.Errorf("compact: manual strategy %q is not supported", cfg.ManualStrategy)
}
}

// planKeepRecent 计算 keep_recent 策略下需要摘要与保留的消息集合。
func planKeepRecent(messages []provider.Message, keepMessages int) compactionPlan {
spans := internalcompact.BuildMessageSpans(messages)
retainedStart := internalcompact.RetainedStartForKeepRecentMessages(spans, keepMessages)
if retainedStart <= 0 {
return compactionPlan{
Retained: cloneMessages(messages),
Applied: false,
}
}

archived, retained := splitMessagesAt(messages, retainedStart)
return compactionPlan{
Archived: archived,
Retained: retained,
ArchivedMessageCount: len(archived),
Applied: len(archived) > 0,
}
}

// planFullReplace 计算 full_replace 策略下需要摘要与保留的消息集合。
func planFullReplace(messages []provider.Message) compactionPlan {
if len(messages) == 0 {
return compactionPlan{}
}

spans := internalcompact.BuildMessageSpans(messages)
retainedStart, hasProtectedTail := internalcompact.ProtectedTailStart(spans)
if !hasProtectedTail {
retainedStart = len(messages)
}

archived, retained := splitMessagesAt(messages, retainedStart)
return compactionPlan{
Archived: archived,
Retained: retained,
ArchivedMessageCount: len(archived),
Applied: len(archived) > 0,
}
}

// splitMessagesAt 按 retained 起点切分 archived 与 retained,并返回深拷贝结果。
func splitMessagesAt(messages []provider.Message, retainedStart int) ([]provider.Message, []provider.Message) {
if retainedStart <= 0 {
return nil, cloneMessages(messages)
}
if retainedStart >= len(messages) {
return cloneMessages(messages), nil
}
return cloneMessages(messages[:retainedStart]), cloneMessages(messages[retainedStart:])
}
Loading
Loading