From d8930a456a121d69cf9798b15bfae0eba2c21357 Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 11 May 2026 00:25:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?pref=EF=BC=88context=EF=BC=89:=E5=88=86?= =?UTF-8?q?=E5=8C=96=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=9D=A5=E6=BA=90=E6=8F=90?= =?UTF-8?q?=E9=AB=98=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E7=8E=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 88 +++++++++++++++++++--- internal/context/builder_test.go | 123 +++++++++++++++++++++++++++++++ internal/context/prompt.go | 14 ++++ internal/context/prompt_test.go | 36 +++++++++ internal/context/types.go | 7 +- internal/runtime/run.go | 37 +++++++++- internal/runtime/runtime_test.go | 6 +- internal/runtime/skills_test.go | 58 ++++++++++++++- internal/runtime/toolexec.go | 14 ++++ 9 files changed, 363 insertions(+), 20 deletions(-) diff --git a/internal/context/builder.go b/internal/context/builder.go index 569f84b8..fdac39cf 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -9,12 +9,43 @@ 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 之前。 +// newStablePromptSources 返回稳定提示词来源列表,适合作为缓存前缀。 +// extra 中的非 nil SectionSource 也会追加到 stable 中(如 memo 持久记忆索引)。 +func newStablePromptSources(extra ...SectionSource) []promptSectionSource { + sources := []promptSectionSource{ + corePromptSource{}, + capabilitiesSource{}, + newRulesPromptSource(nil), + } + for _, src := range extra { + if src != nil { + sources = append(sources, src) + } + } + return sources +} + +// newDynamicPromptSources 返回动态提示词来源列表,随任务进度、会话状态变化。 +func newDynamicPromptSources() []promptSectionSource { + return []promptSectionSource{ + taskStateSource{}, + planModeContextSource{}, + todosSource{}, + skillPromptSource{}, + repositoryContextSource{}, + &systemStateSource{}, + } +} + +// newPromptSources 组装系统提示词来源列表(旧版单列表),将额外 SectionSource 插入到 systemState 之前。 +// 保留用于兼容测试中直接使用 promptSources 的旧构造方式。 // nil 元素会被跳过,不会影响来源顺序。 func newPromptSources(extra ...SectionSource) []promptSectionSource { sources := []promptSectionSource{ @@ -42,9 +73,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, } } @@ -82,20 +114,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 { @@ -107,7 +169,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, diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index de0c92f9..f464043e 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -1230,3 +1230,126 @@ 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, "## Capabilities") { + t.Fatalf("expected Capabilities in stable prompt, got %q", result.StableSystemPrompt) + } + 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) + } +} diff --git a/internal/context/prompt.go b/internal/context/prompt.go index 1074d279..958c24a2 100644 --- a/internal/context/prompt.go +++ b/internal/context/prompt.go @@ -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 { diff --git a/internal/context/prompt_test.go b/internal/context/prompt_test.go index 7c933e8f..cc44d08c 100644 --- a/internal/context/prompt_test.go +++ b/internal/context/prompt_test.go @@ -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() diff --git a/internal/context/types.go b/internal/context/types.go index 94aceb44..a0296d26 100644 --- a/internal/context/types.go +++ b/internal/context/types.go @@ -33,8 +33,13 @@ type BuildInput struct { // BuildResult is the provider-facing context produced for a single round. type BuildResult struct { + // SystemPrompt 是最终拼接结果,兼容旧链路:StableSystemPrompt + "\n\n" + DynamicSystemPrompt。 SystemPrompt string - Messages []providertypes.Message + // StableSystemPrompt 是长期稳定、适合作为缓存前缀的系统提示词。 + StableSystemPrompt string + // DynamicSystemPrompt 是当前轮运行态提示词,随任务进度变化。 + DynamicSystemPrompt string + Messages []providertypes.Message } // RepositorySummarySection 承载 runtime 已决策好的最小 repository summary 投影。 diff --git a/internal/runtime/run.go b/internal/runtime/run.go index fa42daf0..e723866e 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -576,7 +576,7 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState if err != nil { return TurnBudgetSnapshot{}, false, err } - toolSpecs = prioritizeToolSpecsBySkillHints(toolSpecs, activeSkills) + toolSpecs = stableSortToolSpecsByName(toolSpecs) } providerRuntimeCfg, err := resolvedProvider.ToRuntimeConfig() @@ -589,12 +589,27 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState state.mu.Unlock() repeatLimit := resolveRepeatCycleStreakLimit(cfg.Runtime) - systemPrompt := withProgressReminder(builtContext.SystemPrompt, score) + dynamicPrompt := withProgressReminder(builtContext.DynamicSystemPrompt, score) if pendingReminder := drainPendingSystemReminder(state); pendingReminder != "" { - systemPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(systemPrompt, pendingReminder) + dynamicPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(dynamicPrompt, pendingReminder) } if notificationHint := strings.TrimSpace(s.drainHookNotificationsForTurn(state)); notificationHint != "" { - systemPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(systemPrompt, notificationHint) + dynamicPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(dynamicPrompt, notificationHint) + } + systemPrompt := builtContext.SystemPrompt + if builtContext.DynamicSystemPrompt != "" { + systemPrompt = joinRuntimeSystemPromptParts(builtContext.StableSystemPrompt, dynamicPrompt) + } else if builtContext.StableSystemPrompt != "" { + systemPrompt = joinRuntimeSystemPromptParts(builtContext.StableSystemPrompt, dynamicPrompt) + } else { + // 旧 SystemPrompt 路径:directly append reminders. + systemPrompt = withProgressReminder(builtContext.SystemPrompt, score) + if pendingReminder := drainPendingSystemReminder(state); pendingReminder != "" { + systemPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(systemPrompt, pendingReminder) + } + if notificationHint := strings.TrimSpace(s.drainHookNotificationsForTurn(state)); notificationHint != "" { + systemPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(systemPrompt, notificationHint) + } } budgetCfg := cfg budgetCfg.SelectedProvider = resolvedProvider.Name @@ -1122,6 +1137,20 @@ func (s *Service) bindSessionLock(sessionID string) func() { } } +// joinRuntimeSystemPromptParts 拼接 stable 与 dynamic 两部分系统提示词为最终 system prompt。 +// 空部分会被跳过,非空部分之间用两个换行分隔。 +func joinRuntimeSystemPromptParts(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") +} + // withProgressReminder 根据当前 progress 快照选择并注入唯一的自愈提醒。 func withProgressReminder(systemPrompt string, score controlplane.ProgressScore) string { var reminder string diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index f87bede5..6d2c2ee1 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -884,8 +884,10 @@ func (b *stubContextBuilder) Build(ctx context.Context, input agentcontext.Build return b.buildFn(ctx, input) } return agentcontext.BuildResult{ - SystemPrompt: "stub system prompt", - Messages: append([]providertypes.Message(nil), input.Messages...), + SystemPrompt: "stub system prompt", + StableSystemPrompt: "stub stable prompt", + DynamicSystemPrompt: "stub dynamic prompt", + Messages: append([]providertypes.Message(nil), input.Messages...), }, nil } diff --git a/internal/runtime/skills_test.go b/internal/runtime/skills_test.go index 43a41055..68792875 100644 --- a/internal/runtime/skills_test.go +++ b/internal/runtime/skills_test.go @@ -604,7 +604,7 @@ func TestPrioritizeToolSpecsBySkillHintsKeepsNonHintedRelativeOrder(t *testing.T } } -func TestPrepareTurnSnapshotPrioritizesToolsByActiveSkillHints(t *testing.T) { +func TestPrepareTurnSnapshotStableSortsToolsByName(t *testing.T) { t.Parallel() manager := newRuntimeConfigManager(t) @@ -817,3 +817,59 @@ func TestSkillHelperFunctionsBranches(t *testing.T) { t.Fatalf("expected nil for empty active skills") } } + +func TestStableSortToolSpecsByNameSortsByName(t *testing.T) { + t.Parallel() + + specs := []providertypes.ToolSpec{ + {Name: "webfetch"}, + {Name: "bash"}, + {Name: "filesystem_read_file"}, + } + + sorted := stableSortToolSpecsByName(specs) + if len(sorted) != 3 { + t.Fatalf("expected 3 tools, got %d", len(sorted)) + } + if sorted[0].Name != "bash" { + t.Fatalf("expected first tool bash, got %q", sorted[0].Name) + } + if sorted[1].Name != "filesystem_read_file" { + t.Fatalf("expected second tool filesystem_read_file, got %q", sorted[1].Name) + } + if sorted[2].Name != "webfetch" { + t.Fatalf("expected third tool webfetch, got %q", sorted[2].Name) + } +} + +func TestStableSortToolSpecsByNameKeepsSameNameRelativeOrder(t *testing.T) { + t.Parallel() + + // Two specs with same name should keep their original relative order. + specs := []providertypes.ToolSpec{ + {Name: "bash", Description: "first"}, + {Name: "bash", Description: "second"}, + } + + sorted := stableSortToolSpecsByName(specs) + if len(sorted) != 2 { + t.Fatalf("expected 2 tools, got %d", len(sorted)) + } + if sorted[0].Description != "first" { + t.Fatalf("expected first bash entry, got %q", sorted[0].Description) + } + if sorted[1].Description != "second" { + t.Fatalf("expected second bash entry, got %q", sorted[1].Description) + } +} + +func TestStableSortToolSpecsByNameEmpty(t *testing.T) { + t.Parallel() + + if got := stableSortToolSpecsByName(nil); got != nil { + t.Fatalf("expected nil for empty input, got %+v", got) + } + if got := stableSortToolSpecsByName([]providertypes.ToolSpec{}); got != nil { + t.Fatalf("expected nil for empty slice, got %+v", got) + } +} diff --git a/internal/runtime/toolexec.go b/internal/runtime/toolexec.go index c6cc6ee0..98d883ce 100644 --- a/internal/runtime/toolexec.go +++ b/internal/runtime/toolexec.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "sync" @@ -860,3 +861,16 @@ func (s *Service) emitAfterToolFailureHook( Metadata: afterToolFailureMetadata, }) } + +// stableSortToolSpecsByName 按工具名稳定排序工具规格列表,确保多轮请求间前缀稳定。 +// 使用 sort.SliceStable 保证同名工具保持原始相对顺序。 +func stableSortToolSpecsByName(specs []providertypes.ToolSpec) []providertypes.ToolSpec { + if len(specs) == 0 { + return nil + } + sorted := append([]providertypes.ToolSpec(nil), specs...) + sort.SliceStable(sorted, func(i, j int) bool { + return sorted[i].Name < sorted[j].Name + }) + return sorted +} From 709b97e5b5436a90e1864dab84091ebfb6f635df Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 11 May 2026 00:37:19 +0800 Subject: [PATCH 2/3] =?UTF-8?q?pref=EF=BC=88context=EF=BC=89:=20capabiliti?= =?UTF-8?q?esSource=20=E4=BB=8E=20stable=20=E2=86=92=20dynamic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/context/builder.go | 24 +------------------ internal/context/builder_test.go | 8 ++++--- internal/context/source_repository.go | 34 +++++++++++++++++++++++++-- 3 files changed, 38 insertions(+), 28 deletions(-) diff --git a/internal/context/builder.go b/internal/context/builder.go index fdac39cf..0ab6e2f0 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -21,7 +21,6 @@ type DefaultBuilder struct { func newStablePromptSources(extra ...SectionSource) []promptSectionSource { sources := []promptSectionSource{ corePromptSource{}, - capabilitiesSource{}, newRulesPromptSource(nil), } for _, src := range extra { @@ -35,6 +34,7 @@ func newStablePromptSources(extra ...SectionSource) []promptSectionSource { // newDynamicPromptSources 返回动态提示词来源列表,随任务进度、会话状态变化。 func newDynamicPromptSources() []promptSectionSource { return []promptSectionSource{ + capabilitiesSource{}, taskStateSource{}, planModeContextSource{}, todosSource{}, @@ -44,28 +44,6 @@ func newDynamicPromptSources() []promptSectionSource { } } -// newPromptSources 组装系统提示词来源列表(旧版单列表),将额外 SectionSource 插入到 systemState 之前。 -// 保留用于兼容测试中直接使用 promptSources 的旧构造方式。 -// nil 元素会被跳过,不会影响来源顺序。 -func newPromptSources(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{}) -} - // NewConfiguredBuilder 基于聚合配置和可选 SectionSource 列表构建上下文构建器,是推荐的统一构造入口。 // cfg.PinChecker 为 nil 时自动使用默认 pin checker;sources 中 nil 元素会被跳过。 func NewConfiguredBuilder(cfg MicroCompactConfig, sources ...SectionSource) Builder { diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index f464043e..63ae35c7 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -1255,8 +1255,11 @@ func TestDefaultBuilderBuildReturnsStableAndDynamicPrompts(t *testing.T) { if !strings.Contains(result.StableSystemPrompt, "## Agent Identity") { t.Fatalf("expected Agent Identity in stable prompt, got %q", result.StableSystemPrompt) } - if !strings.Contains(result.StableSystemPrompt, "## Capabilities") { - t.Fatalf("expected Capabilities 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) @@ -1267,7 +1270,6 @@ func TestDefaultBuilderBuildReturnsStableAndDynamicPrompts(t *testing.T) { t.Fatalf("SystemPrompt should equal StableSystemPrompt + DynamicSystemPrompt, got %q, expected %q", result.SystemPrompt, expected) } } - func TestDefaultBuilderBuildTodoChangeDoesNotChangeStablePrompt(t *testing.T) { t.Parallel() diff --git a/internal/context/source_repository.go b/internal/context/source_repository.go index 4f25e285..a485589a 100644 --- a/internal/context/source_repository.go +++ b/internal/context/source_repository.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "regexp" + "sort" "strconv" "strings" + + "neo-code/internal/repository" ) // repositoryContextSource 负责把 runtime 决策好的 repository 上下文渲染为单独 section。 @@ -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 { @@ -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 != "" { @@ -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 { @@ -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 != "" { From d6d5bcee1107f788dfb5f7aeefc3b7b70dfd200c Mon Sep 17 00:00:00 2001 From: phantom5099 <1011668688@qq.com> Date: Mon, 11 May 2026 12:36:12 +0800 Subject: [PATCH 3/3] =?UTF-8?q?pref=EF=BC=88runtime=EF=BC=89:=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8Ddestructive=20read=E5=AF=BC=E8=87=B4=E6=B6=88=E6=81=AF?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/runtime/run.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/internal/runtime/run.go b/internal/runtime/run.go index e723866e..2ef090db 100644 --- a/internal/runtime/run.go +++ b/internal/runtime/run.go @@ -589,20 +589,19 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState state.mu.Unlock() repeatLimit := resolveRepeatCycleStreakLimit(cfg.Runtime) - dynamicPrompt := withProgressReminder(builtContext.DynamicSystemPrompt, score) - if pendingReminder := drainPendingSystemReminder(state); pendingReminder != "" { - dynamicPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(dynamicPrompt, pendingReminder) - } - if notificationHint := strings.TrimSpace(s.drainHookNotificationsForTurn(state)); notificationHint != "" { - dynamicPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(dynamicPrompt, notificationHint) - } - systemPrompt := builtContext.SystemPrompt + var systemPrompt string if builtContext.DynamicSystemPrompt != "" { - systemPrompt = joinRuntimeSystemPromptParts(builtContext.StableSystemPrompt, dynamicPrompt) - } else if builtContext.StableSystemPrompt != "" { - systemPrompt = joinRuntimeSystemPromptParts(builtContext.StableSystemPrompt, dynamicPrompt) + // New style: reminders append to dynamicPrompt, then join with stable + prompt := withProgressReminder(builtContext.DynamicSystemPrompt, score) + if pendingReminder := drainPendingSystemReminder(state); pendingReminder != "" { + prompt = mergeEphemeralHookNotificationIntoSystemPrompt(prompt, pendingReminder) + } + if notificationHint := strings.TrimSpace(s.drainHookNotificationsForTurn(state)); notificationHint != "" { + prompt = mergeEphemeralHookNotificationIntoSystemPrompt(prompt, notificationHint) + } + systemPrompt = joinRuntimeSystemPromptParts(builtContext.StableSystemPrompt, prompt) } else { - // 旧 SystemPrompt 路径:directly append reminders. + // Legacy fallback: reminders append to SystemPrompt directly systemPrompt = withProgressReminder(builtContext.SystemPrompt, score) if pendingReminder := drainPendingSystemReminder(state); pendingReminder != "" { systemPrompt = mergeEphemeralHookNotificationIntoSystemPrompt(systemPrompt, pendingReminder)