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
84 changes: 63 additions & 21 deletions internal/context/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,30 +9,39 @@ import (

// DefaultBuilder preserves the current runtime context-building behavior.
type DefaultBuilder struct {
promptSources []promptSectionSource
trimPolicy messageTrimPolicy
microCompactCfg MicroCompactConfig
stablePromptSources []promptSectionSource
dynamicPromptSources []promptSectionSource
promptSources []promptSectionSource
trimPolicy messageTrimPolicy
microCompactCfg MicroCompactConfig
}

// newPromptSources 组装系统提示词来源列表,将额外 SectionSource 插入到 systemState 之前
// nil 元素会被跳过,不会影响来源顺序
func newPromptSources(extra ...SectionSource) []promptSectionSource {
// newStablePromptSources 返回稳定提示词来源列表,适合作为缓存前缀
// extra 中的非 nil SectionSource 也会追加到 stable 中(如 memo 持久记忆索引)
func newStablePromptSources(extra ...SectionSource) []promptSectionSource {
sources := []promptSectionSource{
corePromptSource{},
capabilitiesSource{},
newRulesPromptSource(nil),
taskStateSource{},
planModeContextSource{},
todosSource{},
skillPromptSource{},
}
for _, src := range extra {
if src != nil {
sources = append(sources, src)
}
}
sources = append(sources, repositoryContextSource{})
return append(sources, &systemStateSource{})
return sources
}

// newDynamicPromptSources 返回动态提示词来源列表,随任务进度、会话状态变化。
func newDynamicPromptSources() []promptSectionSource {
return []promptSectionSource{
capabilitiesSource{},
taskStateSource{},
planModeContextSource{},
todosSource{},
skillPromptSource{},
repositoryContextSource{},
&systemStateSource{},
}
}

// NewConfiguredBuilder 基于聚合配置和可选 SectionSource 列表构建上下文构建器,是推荐的统一构造入口。
Expand All @@ -42,9 +51,10 @@ func NewConfiguredBuilder(cfg MicroCompactConfig, sources ...SectionSource) Buil
cfg.PinChecker = NewDefaultPinChecker()
}
return &DefaultBuilder{
promptSources: newPromptSources(sources...),
trimPolicy: spanMessageTrimPolicy{},
microCompactCfg: cfg,
stablePromptSources: newStablePromptSources(sources...),
dynamicPromptSources: newDynamicPromptSources(),
trimPolicy: spanMessageTrimPolicy{},
microCompactCfg: cfg,
}
}

Expand Down Expand Up @@ -82,20 +92,50 @@ func NewBuilderWithMemoAndSummarizers(policies MicroCompactPolicySource, summari
return NewConfiguredBuilder(MicroCompactConfig{Policies: policies, Summarizers: summarizers}, memoSource)
}

// collectPromptSections 遍历 promptSectionSource 列表并收集所有 sections。
func collectPromptSections(ctx context.Context, input BuildInput, sources []promptSectionSource) ([]promptSection, error) {
sections := make([]promptSection, 0, len(sources))
for _, source := range sources {
sourceSections, err := source.Sections(ctx, input)
if err != nil {
return nil, err
}
sections = append(sections, sourceSections...)
}
return sections, nil
}

// Build assembles the provider-facing context for the current round.
func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResult, error) {
if err := ctx.Err(); err != nil {
return BuildResult{}, err
}

sections := make([]promptSection, 0, len(b.promptSources)+1)
for _, source := range b.promptSources {
sourceSections, err := source.Sections(ctx, input)
stableSources := b.stablePromptSources
dynamicSources := b.dynamicPromptSources

// 兼容旧构造方式:如果新字段未设置但旧 promptSources 有内容,回退到旧单列表。
if len(stableSources) == 0 && len(dynamicSources) == 0 && len(b.promptSources) > 0 {
stableSources = b.promptSources
}

var stablePrompt, dynamicPrompt string
if stableSources != nil {
stableSections, err := collectPromptSections(ctx, input, stableSources)
if err != nil {
return BuildResult{}, err
}
sections = append(sections, sourceSections...)
stablePrompt = composeSystemPrompt(stableSections...)
}
if dynamicSources != nil {
dynamicSections, err := collectPromptSections(ctx, input, dynamicSources)
if err != nil {
return BuildResult{}, err
}
dynamicPrompt = composeSystemPrompt(dynamicSections...)
}

systemPrompt := joinSystemPromptParts(stablePrompt, dynamicPrompt)

trimPolicy := b.trimPolicy
if trimPolicy == nil {
Expand All @@ -107,7 +147,9 @@ func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResu
}

return BuildResult{
SystemPrompt: composeSystemPrompt(sections...),
SystemPrompt: systemPrompt,
StableSystemPrompt: stablePrompt,
DynamicSystemPrompt: dynamicPrompt,
Messages: applyReadTimeContextProjection(
trimPolicy.Trim(input.Messages, input.Compact),
input.TaskState,
Expand Down
125 changes: 125 additions & 0 deletions internal/context/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1230,3 +1230,128 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) {
t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata)
}
}

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

builder := NewBuilder()
result, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}

if result.StableSystemPrompt == "" {
t.Fatalf("expected non-empty StableSystemPrompt")
}
if result.DynamicSystemPrompt == "" {
t.Fatalf("expected non-empty DynamicSystemPrompt")
}

if !strings.Contains(result.StableSystemPrompt, "## Agent Identity") {
t.Fatalf("expected Agent Identity in stable prompt, got %q", result.StableSystemPrompt)
}
if !strings.Contains(result.StableSystemPrompt, "## Tool Usage") {
t.Fatalf("expected Tool Usage in stable prompt, got %q", result.StableSystemPrompt)
}
if !strings.Contains(result.DynamicSystemPrompt, "## Capabilities & Limitations") {
t.Fatalf("expected Capabilities & Limitations in dynamic prompt, got %q", result.DynamicSystemPrompt)
}
if !strings.Contains(result.DynamicSystemPrompt, "## System State") {
t.Fatalf("expected System State in dynamic prompt, got %q", result.DynamicSystemPrompt)
}

expected := result.StableSystemPrompt + "\n\n" + result.DynamicSystemPrompt
if result.SystemPrompt != expected {
t.Fatalf("SystemPrompt should equal StableSystemPrompt + DynamicSystemPrompt, got %q, expected %q", result.SystemPrompt, expected)
}
}
func TestDefaultBuilderBuildTodoChangeDoesNotChangeStablePrompt(t *testing.T) {
t.Parallel()

builder := NewBuilder()
baseInput := BuildInput{
Messages: []providertypes.Message{
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Metadata: testMetadata(t.TempDir()),
}

first, err := builder.Build(stdcontext.Background(), baseInput)
if err != nil {
t.Fatalf("first Build() error = %v", err)
}

inputWithTodos := baseInput
inputWithTodos.Todos = []agentsession.TodoItem{
{
ID: "todo-1",
Content: "new todo",
Status: agentsession.TodoStatusPending,
},
}
second, err := builder.Build(stdcontext.Background(), inputWithTodos)
if err != nil {
t.Fatalf("second Build() error = %v", err)
}

if first.StableSystemPrompt != second.StableSystemPrompt {
t.Fatalf("expected StableSystemPrompt unchanged after todo change")
}
if first.DynamicSystemPrompt == second.DynamicSystemPrompt {
t.Fatalf("expected DynamicSystemPrompt to change after todo change")
}
}

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

builder := NewConfiguredBuilder(MicroCompactConfig{}, stubPromptSectionSource{
sections: []promptSection{
NewPromptSection("memo", "remember this"),
},
})

result, err := builder.Build(stdcontext.Background(), BuildInput{
Messages: []providertypes.Message{
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
},
Metadata: testMetadata(t.TempDir()),
})
if err != nil {
t.Fatalf("Build() error = %v", err)
}

if !strings.Contains(result.StableSystemPrompt, "## memo") {
t.Fatalf("expected memo in StableSystemPrompt, got %q", result.StableSystemPrompt)
}
if strings.Contains(result.DynamicSystemPrompt, "## memo") {
t.Fatalf("did not expect memo in DynamicSystemPrompt, got %q", result.DynamicSystemPrompt)
}
}

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

builder := &DefaultBuilder{
promptSources: []promptSectionSource{
stubPromptSectionSource{sections: []promptSection{{Title: "Old", Content: "old style"}}},
},
microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()},
}

result, err := builder.Build(stdcontext.Background(), BuildInput{})
if err != nil {
t.Fatalf("Build() error = %v", err)
}
if !strings.Contains(result.SystemPrompt, "old style") {
t.Fatalf("expected old style content in system prompt, got %q", result.SystemPrompt)
}
if !strings.Contains(result.StableSystemPrompt, "old style") {
t.Fatalf("expected old style content in StableSystemPrompt, got %q", result.StableSystemPrompt)
}
}
14 changes: 14 additions & 0 deletions internal/context/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ func defaultSystemPromptSections() []promptSection {
return sections
}

// joinSystemPromptParts 将 stable 和 dynamic 两部分提示词拼接为最终 SystemPrompt。
// 空部分会被跳过,非空部分之间用两个换行分隔。
func joinSystemPromptParts(parts ...string) string {
rendered := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
rendered = append(rendered, part)
}
return strings.Join(rendered, "\n\n")
}

func composeSystemPrompt(sections ...promptSection) string {
rendered := make([]string, 0, len(sections))
for _, section := range sections {
Expand Down
36 changes: 36 additions & 0 deletions internal/context/prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,42 @@ func TestComposeSystemPromptSkipsEmptySections(t *testing.T) {
}
}

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

got := joinSystemPromptParts("", "stable", "", "dynamic", "")
if got != "stable\n\ndynamic" {
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "stable\n\ndynamic")
}
}

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

got := joinSystemPromptParts("stable content", "dynamic content")
if got != "stable content\n\ndynamic content" {
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "stable content\n\ndynamic content")
}
}

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

got := joinSystemPromptParts("only one part")
if got != "only one part" {
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "only one part")
}
}

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

got := joinSystemPromptParts("", "", "")
if got != "" {
t.Fatalf("joinSystemPromptParts() = %q, want empty string", got)
}
}

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

Expand Down
34 changes: 32 additions & 2 deletions internal/context/source_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"context"
"fmt"
"regexp"
"sort"
"strconv"
"strings"

"neo-code/internal/repository"
)

// repositoryContextSource 负责把 runtime 决策好的 repository 上下文渲染为单独 section。
Expand Down Expand Up @@ -36,6 +39,18 @@ func renderRepositoryContext(repo RepositoryContext) string {
return strings.Join(parts, "\n\n")
}

// stableSortedChangedFiles 按 path 稳定排序 changed files,确保多轮请求间缓存前缀不因顺序抖动。
func stableSortedChangedFiles(section *RepositoryChangedFilesSection) []repository.ChangedFile {
if section == nil || len(section.Files) == 0 {
return nil
}
sorted := append([]repository.ChangedFile(nil), section.Files...)
sort.SliceStable(sorted, func(i, j int) bool {
return sorted[i].Path < sorted[j].Path
})
return sorted
}

// renderChangedFilesRepositoryContext 以紧凑列表渲染当前轮允许注入的 changed-files 摘要。
func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection) string {
if section == nil || len(section.Files) == 0 {
Expand All @@ -48,7 +63,7 @@ func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection)
fmt.Sprintf("- returned_changed_files: `%d`", section.ReturnedCount),
fmt.Sprintf("- truncated: `%t`", section.Truncated),
}
for _, file := range section.Files {
for _, file := range stableSortedChangedFiles(section) {
lines = append(lines, fmt.Sprintf("- status: `%s`", file.Status))
lines = append(lines, " path: "+renderRepositoryScalar(file.Path))
if file.OldPath != "" {
Expand All @@ -61,6 +76,21 @@ func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection)
return strings.Join(lines, "\n")
}

// stableSortedRetrievalHits 按 path + line_hint 排序检索命中,消除不同轮次间的顺序抖动。
func stableSortedRetrievalHits(section *RepositoryRetrievalSection) []repository.RetrievalHit {
if section == nil || len(section.Hits) == 0 {
return nil
}
sorted := append([]repository.RetrievalHit(nil), section.Hits...)
sort.SliceStable(sorted, func(i, j int) bool {
if sorted[i].Path != sorted[j].Path {
return sorted[i].Path < sorted[j].Path
}
return sorted[i].LineHint < sorted[j].LineHint
})
return sorted
}

// renderRetrievalRepositoryContext 以受限格式渲染本轮命中的 targeted retrieval 结果。
func renderRetrievalRepositoryContext(section *RepositoryRetrievalSection) string {
if section == nil || len(section.Hits) == 0 {
Expand All @@ -73,7 +103,7 @@ func renderRetrievalRepositoryContext(section *RepositoryRetrievalSection) strin
"- query: " + renderRepositoryScalar(section.Query),
fmt.Sprintf("- truncated: `%t`", section.Truncated),
}
for _, hit := range section.Hits {
for _, hit := range stableSortedRetrievalHits(section) {
lines = append(lines, "- path: "+renderRepositoryScalar(hit.Path))
lines = append(lines, fmt.Sprintf(" line_hint: `%d`", hit.LineHint))
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {
Expand Down
Loading
Loading