Skip to content

Commit 79ce375

Browse files
authored
Merge pull request #616 from phantom5099/rebuild-verify
pref(context): 改善 Provider 前缀缓存命中基础
2 parents 351ffea + d6e73ed commit 79ce375

10 files changed

Lines changed: 386 additions & 34 deletions

File tree

internal/context/builder.go

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,30 +9,39 @@ import (
99

1010
// DefaultBuilder preserves the current runtime context-building behavior.
1111
type DefaultBuilder struct {
12-
promptSources []promptSectionSource
13-
trimPolicy messageTrimPolicy
14-
microCompactCfg MicroCompactConfig
12+
stablePromptSources []promptSectionSource
13+
dynamicPromptSources []promptSectionSource
14+
promptSources []promptSectionSource
15+
trimPolicy messageTrimPolicy
16+
microCompactCfg MicroCompactConfig
1517
}
1618

17-
// newPromptSources 组装系统提示词来源列表,将额外 SectionSource 插入到 systemState 之前
18-
// nil 元素会被跳过,不会影响来源顺序
19-
func newPromptSources(extra ...SectionSource) []promptSectionSource {
19+
// newStablePromptSources 返回稳定提示词来源列表,适合作为缓存前缀
20+
// extra 中的非 nil SectionSource 也会追加到 stable 中(如 memo 持久记忆索引)
21+
func newStablePromptSources(extra ...SectionSource) []promptSectionSource {
2022
sources := []promptSectionSource{
2123
corePromptSource{},
22-
capabilitiesSource{},
2324
newRulesPromptSource(nil),
24-
taskStateSource{},
25-
planModeContextSource{},
26-
todosSource{},
27-
skillPromptSource{},
2825
}
2926
for _, src := range extra {
3027
if src != nil {
3128
sources = append(sources, src)
3229
}
3330
}
34-
sources = append(sources, repositoryContextSource{})
35-
return append(sources, &systemStateSource{})
31+
return sources
32+
}
33+
34+
// newDynamicPromptSources 返回动态提示词来源列表,随任务进度、会话状态变化。
35+
func newDynamicPromptSources() []promptSectionSource {
36+
return []promptSectionSource{
37+
capabilitiesSource{},
38+
taskStateSource{},
39+
planModeContextSource{},
40+
todosSource{},
41+
skillPromptSource{},
42+
repositoryContextSource{},
43+
&systemStateSource{},
44+
}
3645
}
3746

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

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

95+
// collectPromptSections 遍历 promptSectionSource 列表并收集所有 sections。
96+
func collectPromptSections(ctx context.Context, input BuildInput, sources []promptSectionSource) ([]promptSection, error) {
97+
sections := make([]promptSection, 0, len(sources))
98+
for _, source := range sources {
99+
sourceSections, err := source.Sections(ctx, input)
100+
if err != nil {
101+
return nil, err
102+
}
103+
sections = append(sections, sourceSections...)
104+
}
105+
return sections, nil
106+
}
107+
85108
// Build assembles the provider-facing context for the current round.
86109
func (b *DefaultBuilder) Build(ctx context.Context, input BuildInput) (BuildResult, error) {
87110
if err := ctx.Err(); err != nil {
88111
return BuildResult{}, err
89112
}
90113

91-
sections := make([]promptSection, 0, len(b.promptSources)+1)
92-
for _, source := range b.promptSources {
93-
sourceSections, err := source.Sections(ctx, input)
114+
stableSources := b.stablePromptSources
115+
dynamicSources := b.dynamicPromptSources
116+
117+
// 兼容旧构造方式:如果新字段未设置但旧 promptSources 有内容,回退到旧单列表。
118+
if len(stableSources) == 0 && len(dynamicSources) == 0 && len(b.promptSources) > 0 {
119+
stableSources = b.promptSources
120+
}
121+
122+
var stablePrompt, dynamicPrompt string
123+
if stableSources != nil {
124+
stableSections, err := collectPromptSections(ctx, input, stableSources)
94125
if err != nil {
95126
return BuildResult{}, err
96127
}
97-
sections = append(sections, sourceSections...)
128+
stablePrompt = composeSystemPrompt(stableSections...)
98129
}
130+
if dynamicSources != nil {
131+
dynamicSections, err := collectPromptSections(ctx, input, dynamicSources)
132+
if err != nil {
133+
return BuildResult{}, err
134+
}
135+
dynamicPrompt = composeSystemPrompt(dynamicSections...)
136+
}
137+
138+
systemPrompt := joinSystemPromptParts(stablePrompt, dynamicPrompt)
99139

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

109149
return BuildResult{
110-
SystemPrompt: composeSystemPrompt(sections...),
150+
SystemPrompt: systemPrompt,
151+
StableSystemPrompt: stablePrompt,
152+
DynamicSystemPrompt: dynamicPrompt,
111153
Messages: applyReadTimeContextProjection(
112154
trimPolicy.Trim(input.Messages, input.Compact),
113155
input.TaskState,

internal/context/builder_test.go

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1230,3 +1230,128 @@ func TestDefaultBuilderBuildProjectsMetadataOnlyToolResult(t *testing.T) {
12301230
t.Fatalf("expected projected tool metadata to be cleared, got %#v", toolMessage.ToolMetadata)
12311231
}
12321232
}
1233+
1234+
func TestDefaultBuilderBuildReturnsStableAndDynamicPrompts(t *testing.T) {
1235+
t.Parallel()
1236+
1237+
builder := NewBuilder()
1238+
result, err := builder.Build(stdcontext.Background(), BuildInput{
1239+
Messages: []providertypes.Message{
1240+
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
1241+
},
1242+
Metadata: testMetadata(t.TempDir()),
1243+
})
1244+
if err != nil {
1245+
t.Fatalf("Build() error = %v", err)
1246+
}
1247+
1248+
if result.StableSystemPrompt == "" {
1249+
t.Fatalf("expected non-empty StableSystemPrompt")
1250+
}
1251+
if result.DynamicSystemPrompt == "" {
1252+
t.Fatalf("expected non-empty DynamicSystemPrompt")
1253+
}
1254+
1255+
if !strings.Contains(result.StableSystemPrompt, "## Agent Identity") {
1256+
t.Fatalf("expected Agent Identity in stable prompt, got %q", result.StableSystemPrompt)
1257+
}
1258+
if !strings.Contains(result.StableSystemPrompt, "## Tool Usage") {
1259+
t.Fatalf("expected Tool Usage in stable prompt, got %q", result.StableSystemPrompt)
1260+
}
1261+
if !strings.Contains(result.DynamicSystemPrompt, "## Capabilities & Limitations") {
1262+
t.Fatalf("expected Capabilities & Limitations in dynamic prompt, got %q", result.DynamicSystemPrompt)
1263+
}
1264+
if !strings.Contains(result.DynamicSystemPrompt, "## System State") {
1265+
t.Fatalf("expected System State in dynamic prompt, got %q", result.DynamicSystemPrompt)
1266+
}
1267+
1268+
expected := result.StableSystemPrompt + "\n\n" + result.DynamicSystemPrompt
1269+
if result.SystemPrompt != expected {
1270+
t.Fatalf("SystemPrompt should equal StableSystemPrompt + DynamicSystemPrompt, got %q, expected %q", result.SystemPrompt, expected)
1271+
}
1272+
}
1273+
func TestDefaultBuilderBuildTodoChangeDoesNotChangeStablePrompt(t *testing.T) {
1274+
t.Parallel()
1275+
1276+
builder := NewBuilder()
1277+
baseInput := BuildInput{
1278+
Messages: []providertypes.Message{
1279+
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
1280+
},
1281+
Metadata: testMetadata(t.TempDir()),
1282+
}
1283+
1284+
first, err := builder.Build(stdcontext.Background(), baseInput)
1285+
if err != nil {
1286+
t.Fatalf("first Build() error = %v", err)
1287+
}
1288+
1289+
inputWithTodos := baseInput
1290+
inputWithTodos.Todos = []agentsession.TodoItem{
1291+
{
1292+
ID: "todo-1",
1293+
Content: "new todo",
1294+
Status: agentsession.TodoStatusPending,
1295+
},
1296+
}
1297+
second, err := builder.Build(stdcontext.Background(), inputWithTodos)
1298+
if err != nil {
1299+
t.Fatalf("second Build() error = %v", err)
1300+
}
1301+
1302+
if first.StableSystemPrompt != second.StableSystemPrompt {
1303+
t.Fatalf("expected StableSystemPrompt unchanged after todo change")
1304+
}
1305+
if first.DynamicSystemPrompt == second.DynamicSystemPrompt {
1306+
t.Fatalf("expected DynamicSystemPrompt to change after todo change")
1307+
}
1308+
}
1309+
1310+
func TestDefaultBuilderBuildMemoIsStable(t *testing.T) {
1311+
t.Parallel()
1312+
1313+
builder := NewConfiguredBuilder(MicroCompactConfig{}, stubPromptSectionSource{
1314+
sections: []promptSection{
1315+
NewPromptSection("memo", "remember this"),
1316+
},
1317+
})
1318+
1319+
result, err := builder.Build(stdcontext.Background(), BuildInput{
1320+
Messages: []providertypes.Message{
1321+
{Role: "user", Parts: []providertypes.ContentPart{providertypes.NewTextPart("hello")}},
1322+
},
1323+
Metadata: testMetadata(t.TempDir()),
1324+
})
1325+
if err != nil {
1326+
t.Fatalf("Build() error = %v", err)
1327+
}
1328+
1329+
if !strings.Contains(result.StableSystemPrompt, "## memo") {
1330+
t.Fatalf("expected memo in StableSystemPrompt, got %q", result.StableSystemPrompt)
1331+
}
1332+
if strings.Contains(result.DynamicSystemPrompt, "## memo") {
1333+
t.Fatalf("did not expect memo in DynamicSystemPrompt, got %q", result.DynamicSystemPrompt)
1334+
}
1335+
}
1336+
1337+
func TestDefaultBuilderBuildStableAndDynamicPreservesBackwardCompat(t *testing.T) {
1338+
t.Parallel()
1339+
1340+
builder := &DefaultBuilder{
1341+
promptSources: []promptSectionSource{
1342+
stubPromptSectionSource{sections: []promptSection{{Title: "Old", Content: "old style"}}},
1343+
},
1344+
microCompactCfg: MicroCompactConfig{PinChecker: NewDefaultPinChecker()},
1345+
}
1346+
1347+
result, err := builder.Build(stdcontext.Background(), BuildInput{})
1348+
if err != nil {
1349+
t.Fatalf("Build() error = %v", err)
1350+
}
1351+
if !strings.Contains(result.SystemPrompt, "old style") {
1352+
t.Fatalf("expected old style content in system prompt, got %q", result.SystemPrompt)
1353+
}
1354+
if !strings.Contains(result.StableSystemPrompt, "old style") {
1355+
t.Fatalf("expected old style content in StableSystemPrompt, got %q", result.StableSystemPrompt)
1356+
}
1357+
}

internal/context/prompt.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,20 @@ func defaultSystemPromptSections() []promptSection {
3232
return sections
3333
}
3434

35+
// joinSystemPromptParts 将 stable 和 dynamic 两部分提示词拼接为最终 SystemPrompt。
36+
// 空部分会被跳过,非空部分之间用两个换行分隔。
37+
func joinSystemPromptParts(parts ...string) string {
38+
rendered := make([]string, 0, len(parts))
39+
for _, part := range parts {
40+
part = strings.TrimSpace(part)
41+
if part == "" {
42+
continue
43+
}
44+
rendered = append(rendered, part)
45+
}
46+
return strings.Join(rendered, "\n\n")
47+
}
48+
3549
func composeSystemPrompt(sections ...promptSection) string {
3650
rendered := make([]string, 0, len(sections))
3751
for _, section := range sections {

internal/context/prompt_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,42 @@ func TestComposeSystemPromptSkipsEmptySections(t *testing.T) {
9999
}
100100
}
101101

102+
func TestJoinSystemPromptPartsSkipsEmptyParts(t *testing.T) {
103+
t.Parallel()
104+
105+
got := joinSystemPromptParts("", "stable", "", "dynamic", "")
106+
if got != "stable\n\ndynamic" {
107+
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "stable\n\ndynamic")
108+
}
109+
}
110+
111+
func TestJoinSystemPromptPartsPreservesStableBeforeDynamic(t *testing.T) {
112+
t.Parallel()
113+
114+
got := joinSystemPromptParts("stable content", "dynamic content")
115+
if got != "stable content\n\ndynamic content" {
116+
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "stable content\n\ndynamic content")
117+
}
118+
}
119+
120+
func TestJoinSystemPromptPartsSinglePart(t *testing.T) {
121+
t.Parallel()
122+
123+
got := joinSystemPromptParts("only one part")
124+
if got != "only one part" {
125+
t.Fatalf("joinSystemPromptParts() = %q, want %q", got, "only one part")
126+
}
127+
}
128+
129+
func TestJoinSystemPromptPartsAllEmpty(t *testing.T) {
130+
t.Parallel()
131+
132+
got := joinSystemPromptParts("", "", "")
133+
if got != "" {
134+
t.Fatalf("joinSystemPromptParts() = %q, want empty string", got)
135+
}
136+
}
137+
102138
func TestDefaultToolUsagePromptIncludesPermissionAndAntiLoopGuidance(t *testing.T) {
103139
t.Parallel()
104140

internal/context/source_repository.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ import (
44
"context"
55
"fmt"
66
"regexp"
7+
"sort"
78
"strconv"
89
"strings"
10+
11+
"neo-code/internal/repository"
912
)
1013

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

42+
// stableSortedChangedFiles 按 path 稳定排序 changed files,确保多轮请求间缓存前缀不因顺序抖动。
43+
func stableSortedChangedFiles(section *RepositoryChangedFilesSection) []repository.ChangedFile {
44+
if section == nil || len(section.Files) == 0 {
45+
return nil
46+
}
47+
sorted := append([]repository.ChangedFile(nil), section.Files...)
48+
sort.SliceStable(sorted, func(i, j int) bool {
49+
return sorted[i].Path < sorted[j].Path
50+
})
51+
return sorted
52+
}
53+
3954
// renderChangedFilesRepositoryContext 以紧凑列表渲染当前轮允许注入的 changed-files 摘要。
4055
func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection) string {
4156
if section == nil || len(section.Files) == 0 {
@@ -48,7 +63,7 @@ func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection)
4863
fmt.Sprintf("- returned_changed_files: `%d`", section.ReturnedCount),
4964
fmt.Sprintf("- truncated: `%t`", section.Truncated),
5065
}
51-
for _, file := range section.Files {
66+
for _, file := range stableSortedChangedFiles(section) {
5267
lines = append(lines, fmt.Sprintf("- status: `%s`", file.Status))
5368
lines = append(lines, " path: "+renderRepositoryScalar(file.Path))
5469
if file.OldPath != "" {
@@ -61,6 +76,21 @@ func renderChangedFilesRepositoryContext(section *RepositoryChangedFilesSection)
6176
return strings.Join(lines, "\n")
6277
}
6378

79+
// stableSortedRetrievalHits 按 path + line_hint 排序检索命中,消除不同轮次间的顺序抖动。
80+
func stableSortedRetrievalHits(section *RepositoryRetrievalSection) []repository.RetrievalHit {
81+
if section == nil || len(section.Hits) == 0 {
82+
return nil
83+
}
84+
sorted := append([]repository.RetrievalHit(nil), section.Hits...)
85+
sort.SliceStable(sorted, func(i, j int) bool {
86+
if sorted[i].Path != sorted[j].Path {
87+
return sorted[i].Path < sorted[j].Path
88+
}
89+
return sorted[i].LineHint < sorted[j].LineHint
90+
})
91+
return sorted
92+
}
93+
6494
// renderRetrievalRepositoryContext 以受限格式渲染本轮命中的 targeted retrieval 结果。
6595
func renderRetrievalRepositoryContext(section *RepositoryRetrievalSection) string {
6696
if section == nil || len(section.Hits) == 0 {
@@ -73,7 +103,7 @@ func renderRetrievalRepositoryContext(section *RepositoryRetrievalSection) strin
73103
"- query: " + renderRepositoryScalar(section.Query),
74104
fmt.Sprintf("- truncated: `%t`", section.Truncated),
75105
}
76-
for _, hit := range section.Hits {
106+
for _, hit := range stableSortedRetrievalHits(section) {
77107
lines = append(lines, "- path: "+renderRepositoryScalar(hit.Path))
78108
lines = append(lines, fmt.Sprintf(" line_hint: `%d`", hit.LineHint))
79109
if snippet := strings.TrimSpace(hit.Snippet); snippet != "" {

0 commit comments

Comments
 (0)