diff --git a/docs/prompt-compatibility.md b/docs/prompt-compatibility.md index a91fc4479..2041f5d45 100644 --- a/docs/prompt-compatibility.md +++ b/docs/prompt-compatibility.md @@ -299,25 +299,30 @@ OpenAI 的文件上传现在不再是“只传文件本体”的通用路径, - 全局 completion runtime 应用点: [internal/completionruntime/nonstream.go](../internal/completionruntime/nonstream.go) -当前输入转文件启用并触发时,上传的历史文件名由 `filename_policy` 决定,文件内容是完整 `messages` 上下文;`natural_context` 策略会生成自然化 conversation context(不再注入文件边界标签): +当前输入转文件启用并触发时,上传的历史文件名由 `filename_policy` 决定,文件内容来自完整 `messages` 上下文;默认 `hybrid_recent` 策略会生成带语义压缩的 conversation context(不再注入文件边界标签): ```text [uploaded filename]: conversation-notes-a1b2c3.txt # Conversation Context -This note captures the working conversation state for the current request. +This note summarizes earlier context and preserves the most recent turns needed for the current reply. -## Conversation +## Current Task +最新用户请求原文。 -1. System guidance: -... +## Non-Negotiable Instructions -2. User: -... +- system / developer 约束摘要。 + +## Earlier Context Summary + +- 较早历史的短摘要、已确认决策、已完成工具结果。 + +## Recent Exact Context -3. Assistant: +1. User: ... -4. Tool result (name: search, call id: call-1): +2. Tool result (name: search, call id: call-1): ... ``` diff --git a/internal/httpapi/openai/history/current_input_cache_test.go b/internal/httpapi/openai/history/current_input_cache_test.go index 81397e31c..a54f13b3c 100644 --- a/internal/httpapi/openai/history/current_input_cache_test.go +++ b/internal/httpapi/openai/history/current_input_cache_test.go @@ -218,7 +218,7 @@ func TestApplyCurrentInputFileGoldenTranscriptStability(t *testing.T) { if len(ds.uploads) != 2 { t.Fatalf("expected history and tools uploads, got %d", len(ds.uploads)) } - const wantHistory = "# Conversation Context\nThis note captures the working conversation state for the current request.\n\n## Conversation\n\n1. System guidance:\nsystem rule\n\n2. User:\nsearch docs\n" + const wantHistory = "# Conversation Context\nThis note summarizes earlier context and preserves the most recent turns needed for the current reply.\n\n## Current Task\nsearch docs\n\n## Non-Negotiable Instructions\n\n- system rule\n\n## Recent Exact Context\n\n1. System guidance:\nsystem rule\n\n2. User:\nsearch docs\n" if got := string(ds.uploads[0].Data); got != wantHistory { t.Fatalf("history transcript mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, wantHistory) } diff --git a/internal/httpapi/openai/history/current_input_file.go b/internal/httpapi/openai/history/current_input_file.go index fb4ed62ed..2a82e6d6e 100644 --- a/internal/httpapi/openai/history/current_input_file.go +++ b/internal/httpapi/openai/history/current_input_file.go @@ -205,7 +205,7 @@ func currentInputFilenamePolicy(store CurrentInputConfigReader) string { func currentInputContextStrategy(store CurrentInputConfigReader) string { reader, ok := store.(currentInputContextStrategyReader) if !ok { - return "natural_context" + return "hybrid_recent" } return config.NormalizeContextEngineStrategy(reader.ContextEngineStrategy()) } diff --git a/internal/promptcompat/history_transcript.go b/internal/promptcompat/history_transcript.go index 2c3422feb..d7c237064 100644 --- a/internal/promptcompat/history_transcript.go +++ b/internal/promptcompat/history_transcript.go @@ -11,6 +11,15 @@ const historyTranscriptTitle = "# DS2API_HISTORY.txt" const historyTranscriptSummary = "Prior conversation history and tool progress." const naturalContextTitle = "# Conversation Context" const naturalContextSummary = "This note captures the working conversation state for the current request." +const hybridRecentExactEntries = 8 +const hybridSummaryMaxEntries = 12 +const hybridSummarySnippetRunes = 260 + +type contextHistoryEntry struct { + Role string + Label string + Content string +} func BuildOpenAIHistoryTranscript(messages []any) string { return buildOpenAIHistoryTranscript(messages) @@ -33,8 +42,12 @@ func BuildOpenAICurrentInputContextTranscriptWithStrategy(messages []any, strate switch strings.ToLower(strings.TrimSpace(strategy)) { case "raw_transcript": return buildOpenAIHistoryTranscript(messages) - default: + case "natural_context": return buildOpenAINaturalContextTranscript(messages) + case "context_capsule": + return buildOpenAIHybridRecentContextTranscript(messages, false) + default: + return buildOpenAIHybridRecentContextTranscript(messages, true) } } @@ -75,7 +88,8 @@ func buildOpenAIHistoryTranscript(messages []any) string { } func buildOpenAINaturalContextTranscript(messages []any) string { - if len(messages) == 0 { + entries := collectContextHistoryEntries(messages) + if len(entries) == 0 { return "" } var b strings.Builder @@ -86,6 +100,53 @@ func buildOpenAINaturalContextTranscript(messages []any) string { b.WriteString("## Conversation\n\n") entry := 0 + for _, item := range entries { + entry++ + fmt.Fprintf(&b, "%d. %s:\n%s\n\n", entry, item.Label, item.Content) + } + + transcript := strings.TrimSpace(b.String()) + if transcript == "" { + return "" + } + return transcript + "\n" +} + +func buildOpenAIHybridRecentContextTranscript(messages []any, includeRecentExact bool) string { + entries := collectContextHistoryEntries(messages) + if len(entries) == 0 { + return "" + } + var b strings.Builder + b.WriteString(naturalContextTitle) + b.WriteString("\n") + b.WriteString("This note summarizes earlier context and preserves the most recent turns needed for the current reply.") + b.WriteString("\n\n") + + if latest := latestUserContextEntry(entries); latest.Content != "" { + b.WriteString("## Current Task\n") + b.WriteString(latest.Content) + b.WriteString("\n\n") + } + + writeSystemGuidanceSection(&b, entries) + writeEarlierSummarySection(&b, entries, includeRecentExact) + if includeRecentExact { + writeRecentExactSection(&b, entries) + } + + transcript := strings.TrimSpace(b.String()) + if transcript == "" { + return "" + } + return transcript + "\n" +} + +func collectContextHistoryEntries(messages []any) []contextHistoryEntry { + if len(messages) == 0 { + return nil + } + entries := make([]contextHistoryEntry, 0, len(messages)) for _, raw := range messages { msg, ok := raw.(map[string]any) if !ok { @@ -96,15 +157,96 @@ func buildOpenAINaturalContextTranscript(messages []any) string { if content == "" { continue } - entry++ - fmt.Fprintf(&b, "%d. %s:\n%s\n\n", entry, naturalRoleLabelForHistory(role, msg), content) + entries = append(entries, contextHistoryEntry{ + Role: role, + Label: naturalRoleLabelForHistory(role, msg), + Content: content, + }) } + return entries +} - transcript := strings.TrimSpace(b.String()) - if transcript == "" { - return "" +func latestUserContextEntry(entries []contextHistoryEntry) contextHistoryEntry { + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].Role == "user" { + return entries[i] + } } - return transcript + "\n" + return contextHistoryEntry{} +} + +func writeSystemGuidanceSection(b *strings.Builder, entries []contextHistoryEntry) { + wroteHeader := false + for _, entry := range entries { + if entry.Role != "system" { + continue + } + if !wroteHeader { + b.WriteString("## Non-Negotiable Instructions\n\n") + wroteHeader = true + } + b.WriteString("- ") + b.WriteString(oneLineSnippet(entry.Content, hybridSummarySnippetRunes)) + b.WriteString("\n") + } + if wroteHeader { + b.WriteString("\n") + } +} + +func writeEarlierSummarySection(b *strings.Builder, entries []contextHistoryEntry, includeRecentExact bool) { + summaryEnd := len(entries) + if includeRecentExact { + summaryEnd -= hybridRecentExactEntries + if summaryEnd < 0 { + summaryEnd = 0 + } + } + if summaryEnd <= 0 { + return + } + start := 0 + if summaryEnd > hybridSummaryMaxEntries { + start = summaryEnd - hybridSummaryMaxEntries + } + b.WriteString("## Earlier Context Summary\n\n") + if start > 0 { + fmt.Fprintf(b, "- %d older messages omitted after summarization.\n", start) + } + for _, entry := range entries[start:summaryEnd] { + if entry.Role == "system" { + continue + } + fmt.Fprintf(b, "- %s: %s\n", entry.Label, oneLineSnippet(entry.Content, hybridSummarySnippetRunes)) + } + b.WriteString("\n") +} + +func writeRecentExactSection(b *strings.Builder, entries []contextHistoryEntry) { + start := 0 + if len(entries) > hybridRecentExactEntries { + start = len(entries) - hybridRecentExactEntries + } + recent := entries[start:] + if len(recent) == 0 { + return + } + b.WriteString("## Recent Exact Context\n\n") + for i, entry := range recent { + fmt.Fprintf(b, "%d. %s:\n%s\n\n", i+1, entry.Label, entry.Content) + } +} + +func oneLineSnippet(text string, maxRunes int) string { + text = strings.Join(strings.Fields(strings.TrimSpace(text)), " ") + if maxRunes <= 0 { + return text + } + runes := []rune(text) + if len(runes) <= maxRunes { + return text + } + return string(runes[:maxRunes]) + "..." } func buildOpenAIHistoryEntry(role string, msg map[string]any) string { diff --git a/internal/promptcompat/history_transcript_test.go b/internal/promptcompat/history_transcript_test.go new file mode 100644 index 000000000..c3a3948d5 --- /dev/null +++ b/internal/promptcompat/history_transcript_test.go @@ -0,0 +1,62 @@ +package promptcompat + +import ( + "fmt" + "strings" + "testing" +) + +func TestBuildOpenAICurrentInputContextTranscriptHybridRecentCompressesOlderMessages(t *testing.T) { + messages := []any{ + map[string]any{"role": "system", "content": "keep user constraints exact"}, + } + for i := 1; i <= 12; i++ { + messages = append(messages, map[string]any{"role": "user", "content": fmt.Sprintf("older detail %02d with repeated background", i)}) + messages = append(messages, map[string]any{"role": "assistant", "content": fmt.Sprintf("assistant response %02d with repeated explanation", i)}) + } + messages = append(messages, map[string]any{"role": "user", "content": "latest request must be exact"}) + + got := BuildOpenAICurrentInputContextTranscriptWithStrategy(messages, "hybrid_recent") + for _, want := range []string{ + "# Conversation Context", + "## Current Task\nlatest request must be exact", + "## Non-Negotiable Instructions", + "## Earlier Context Summary", + "## Recent Exact Context", + "latest request must be exact", + } { + if !strings.Contains(got, want) { + t.Fatalf("expected hybrid context to contain %q, got %q", want, got) + } + } + if strings.Contains(got, "DS2API") { + t.Fatalf("hybrid context should not expose implementation terms, got %q", got) + } + if strings.Contains(got, "older detail 01 with repeated background\n\n") { + t.Fatalf("oldest messages should be summarized, not kept as exact turns: %q", got) + } +} + +func TestBuildOpenAICurrentInputContextTranscriptStrategyShapes(t *testing.T) { + messages := []any{ + map[string]any{"role": "system", "content": "system rule"}, + map[string]any{"role": "user", "content": "first turn"}, + map[string]any{"role": "assistant", "content": "first answer"}, + map[string]any{"role": "user", "content": "latest request"}, + } + + raw := BuildOpenAICurrentInputContextTranscriptWithStrategy(messages, "raw_transcript") + if !strings.Contains(raw, "# DS2API_HISTORY.txt") || !strings.Contains(raw, "=== 1. SYSTEM ===") { + t.Fatalf("expected raw transcript shape, got %q", raw) + } + + natural := BuildOpenAICurrentInputContextTranscriptWithStrategy(messages, "natural_context") + if !strings.Contains(natural, "## Conversation") || strings.Contains(natural, "Earlier Context Summary") { + t.Fatalf("expected full natural context shape, got %q", natural) + } + + capsule := BuildOpenAICurrentInputContextTranscriptWithStrategy(messages, "context_capsule") + if !strings.Contains(capsule, "## Current Task") || strings.Contains(capsule, "## Recent Exact Context") { + t.Fatalf("expected context capsule without recent exact section, got %q", capsule) + } +}