diff --git a/internal/context/builder.go b/internal/context/builder.go index 569f84b8..0ab6e2f0 100644 --- a/internal/context/builder.go +++ b/internal/context/builder.go @@ -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 列表构建上下文构建器,是推荐的统一构造入口。 @@ -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, } } @@ -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 { @@ -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, diff --git a/internal/context/builder_test.go b/internal/context/builder_test.go index de0c92f9..63ae35c7 100644 --- a/internal/context/builder_test.go +++ b/internal/context/builder_test.go @@ -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) + } +} 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/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 != "" { 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..2ef090db 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,26 @@ func (s *Service) prepareTurnBudgetSnapshot(ctx context.Context, state *runState state.mu.Unlock() repeatLimit := resolveRepeatCycleStreakLimit(cfg.Runtime) - 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) + var systemPrompt string + if builtContext.DynamicSystemPrompt != "" { + // 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 { + // Legacy fallback: reminders append to SystemPrompt directly + 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 +1136,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 +}