Skip to content

Commit 5f87ee3

Browse files
authored
Merge pull request #439 from DEEIX-AI/reasoning_content
feat: support reasoning content passback feature
2 parents d1d4dd6 + cbbaa2b commit 5f87ee3

22 files changed

Lines changed: 259 additions & 43 deletions

File tree

backend/internal/application/conversation/prompt_scope.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,24 @@ func messageIDSet(messages []model.Message) map[uint]struct{} {
8989
return result
9090
}
9191

92-
func historyMessagesFromDomain(messages []model.Message) []llm.Message {
92+
type historyMessageOptions struct {
93+
ReasoningContentPassback bool
94+
}
95+
96+
func historyMessagesFromDomain(messages []model.Message, options historyMessageOptions) []llm.Message {
9397
historyMsgs := make([]llm.Message, 0, len(messages))
9498
for _, item := range messages {
9599
if item.Role != "user" && item.Role != "assistant" && item.Role != "system" {
96100
continue
97101
}
98-
historyMsgs = append(historyMsgs, llm.Message{
102+
message := llm.Message{
99103
Role: item.Role,
100104
Content: item.Content,
101-
})
105+
}
106+
if options.ReasoningContentPassback && item.Role == "assistant" {
107+
message.ReasoningContent = item.ReasoningContent
108+
}
109+
historyMsgs = append(historyMsgs, message)
102110
}
103111
return historyMsgs
104112
}

backend/internal/application/conversation/prompt_scope_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,40 @@ func TestPromptScopeFilterRecallChunksDropsCoveredMessages(t *testing.T) {
7474
}
7575
}
7676

77+
func TestHistoryMessagesFromDomainPassesBackAssistantReasoningWhenEnabled(t *testing.T) {
78+
messages := []model.Message{
79+
{Role: "user", Content: "question", ReasoningContent: "ignored"},
80+
{Role: "assistant", Content: "answer", ReasoningContent: "thinking"},
81+
}
82+
83+
got := historyMessagesFromDomain(messages, historyMessageOptions{ReasoningContentPassback: true})
84+
85+
if len(got) != 2 {
86+
t.Fatalf("expected 2 history messages, got %d", len(got))
87+
}
88+
if got[0].ReasoningContent != "" {
89+
t.Fatalf("expected user reasoning to be ignored, got %q", got[0].ReasoningContent)
90+
}
91+
if got[1].ReasoningContent != "thinking" {
92+
t.Fatalf("expected assistant reasoning passback, got %q", got[1].ReasoningContent)
93+
}
94+
}
95+
96+
func TestHistoryMessagesFromDomainOmitsAssistantReasoningWhenDisabled(t *testing.T) {
97+
messages := []model.Message{
98+
{Role: "assistant", Content: "answer", ReasoningContent: "thinking"},
99+
}
100+
101+
got := historyMessagesFromDomain(messages, historyMessageOptions{ReasoningContentPassback: false})
102+
103+
if len(got) != 1 {
104+
t.Fatalf("expected 1 history message, got %d", len(got))
105+
}
106+
if got[0].ReasoningContent != "" {
107+
t.Fatalf("expected reasoning content to be omitted, got %q", got[0].ReasoningContent)
108+
}
109+
}
110+
77111
func promptScopeMessages() []model.Message {
78112
firstID := uint(1)
79113
secondID := uint(2)

backend/internal/application/conversation/service_branch.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -252,25 +252,25 @@ func (s *Service) expandContextMessagesToSnapshotBoundary(
252252
}
253253

254254
// applyContextTokenBudget 按模型 Token 预算截断,保留最近消息。
255-
func (s *Service) applyContextTokenBudget(messages []model.Message, capabilityModelName string, capabilitiesJSON string) []model.Message {
255+
func (s *Service) applyContextTokenBudget(messages []model.Message, capabilityModelName string, capabilitiesJSON string, includeReasoningContent bool) []model.Message {
256256
cfg := s.cfg.Snapshot()
257257
if !cfg.ContextTokenBudgetEnabled || len(messages) <= 1 {
258258
return messages
259259
}
260260
budget := llm.EffectiveContextBudgetFromCapabilities(capabilityModelName, capabilitiesJSON)
261-
return truncateContextByTokenBudget(messages, budget)
261+
return truncateContextByTokenBudget(messages, budget, includeReasoningContent)
262262
}
263263

264264
// truncateContextByTokenBudget 从最近消息开始,保留在 budgetTokens 以内的消息。
265265
// 始终保留最后一条消息(当前用户输入)。
266-
func truncateContextByTokenBudget(messages []model.Message, budgetTokens int) []model.Message {
266+
func truncateContextByTokenBudget(messages []model.Message, budgetTokens int, includeReasoningContent bool) []model.Message {
267267
if budgetTokens <= 0 || len(messages) == 0 {
268268
return messages
269269
}
270270
total := 0
271271
cutFrom := len(messages)
272272
for i := len(messages) - 1; i >= 0; i-- {
273-
msgTokens := int(estimateTokens(messages[i].Content))
273+
msgTokens := int(estimateDomainMessageTokens(messages[i], includeReasoningContent))
274274
if total+msgTokens > budgetTokens && cutFrom < len(messages) {
275275
break
276276
}
@@ -280,6 +280,14 @@ func truncateContextByTokenBudget(messages []model.Message, budgetTokens int) []
280280
return messages[cutFrom:]
281281
}
282282

283+
func estimateDomainMessageTokens(message model.Message, includeReasoningContent bool) int64 {
284+
tokens := estimateTokens(message.Content)
285+
if includeReasoningContent && message.Role == "assistant" {
286+
tokens += estimateTokens(message.ReasoningContent)
287+
}
288+
return tokens
289+
}
290+
283291
func buildRAGQuery(contextMessages []model.Message, currentContent string, historyTurns int) string {
284292
current := strings.TrimSpace(currentContent)
285293
if historyTurns <= 0 || len(contextMessages) == 0 {

backend/internal/application/conversation/service_branch_test.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,24 @@ func TestSelectLatestDefaultParentCandidateFallsBackToSuccessfulUser(t *testing.
112112
}
113113
}
114114

115+
func TestTruncateContextByTokenBudgetCountsAssistantReasoningWhenEnabled(t *testing.T) {
116+
messages := []model.Message{
117+
{ID: 1, Role: "user", Content: "first"},
118+
{ID: 2, Role: "assistant", Content: "ok", ReasoningContent: "this reasoning content is deliberately long enough to exceed the tiny budget"},
119+
{ID: 3, Role: "user", Content: "next"},
120+
}
121+
122+
withReasoning := truncateContextByTokenBudget(messages, 6, true)
123+
if len(withReasoning) != 1 || withReasoning[0].ID != 3 {
124+
t.Fatalf("expected only latest message when reasoning is counted, got %#v", withReasoning)
125+
}
126+
127+
withoutReasoning := truncateContextByTokenBudget(messages, 6, false)
128+
if len(withoutReasoning) != 3 {
129+
t.Fatalf("expected all messages when reasoning is omitted, got %#v", withoutReasoning)
130+
}
131+
}
132+
115133
func TestBuildBranchMessagePathReusesExistingUserForAssistantRetry(t *testing.T) {
116134
rootID := uint(1)
117135
userID := uint(2)

backend/internal/application/conversation/service_message_completion.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type persistMessageGenerationInput struct {
2020
UserMessage *model.Message
2121
AssistantMessage *model.Message
2222
AssistantText string
23+
AssistantReasoningContent string
2324
InputTokens int64
2425
CacheReadTokens int64
2526
CacheWriteTokens int64
@@ -104,6 +105,7 @@ func (s *Service) persistSuccessfulMessageGeneration(ctx context.Context, input
104105
input.AssistantMessage.ID,
105106
repository.AssistantMessageCompletionUpdate{
106107
Content: input.AssistantText,
108+
ReasoningContent: input.AssistantReasoningContent,
107109
InputTokens: assistantCompletionInputTokens(input),
108110
OutputTokens: input.OutputTokens,
109111
CacheReadTokens: assistantCompletionCacheReadTokens(input),
@@ -116,6 +118,7 @@ func (s *Service) persistSuccessfulMessageGeneration(ctx context.Context, input
116118
return err
117119
}
118120
input.AssistantMessage.Content = input.AssistantText
121+
input.AssistantMessage.ReasoningContent = input.AssistantReasoningContent
119122
input.AssistantMessage.TokenUsage = input.OutputTokens + input.ReasoningTokens
120123
input.AssistantMessage.OutputTokens = input.OutputTokens
121124
input.AssistantMessage.ReasoningTokens = input.ReasoningTokens

backend/internal/application/conversation/service_message_context.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,9 @@ func estimateMessageTokens(message llm.Message) int64 {
7474
for _, part := range message.Parts {
7575
tokens += estimateContentPartTokens(part)
7676
}
77-
return tokens
77+
return tokens + estimateTokens(message.ReasoningContent)
7878
}
79-
return tokens + estimateTokens(message.Content)
79+
return tokens + estimateTokens(message.Content) + estimateTokens(message.ReasoningContent)
8080
}
8181

8282
func estimatePromptTokens(messages []llm.Message) int64 {

backend/internal/application/conversation/service_message_send.go

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import (
2222
"go.uber.org/zap"
2323
)
2424

25+
const reasoningContentPassbackSettingKey = "chat.reasoning_content_passback"
26+
2527
// SendMessage 发送消息并调用上游渠道对话接口,支持多模态附件。
2628
func (s *Service) SendMessage(ctx context.Context, input SendMessageInput) (result *SendMessageResult, retErr error) {
2729
return s.sendMessageInternal(ctx, input, nil, false)
@@ -39,6 +41,14 @@ func (s *Service) StreamMessage(
3941
return s.sendMessageInternal(ctx, input, onDelta, true)
4042
}
4143

44+
func (s *Service) reasoningContentPassbackEnabled(ctx context.Context, userID uint, route *channel.ResolvedRoute) bool {
45+
if route == nil || !route.ReasoningContentPassback {
46+
return false
47+
}
48+
value, err := s.getUserSettingCached(ctx, userID, reasoningContentPassbackSettingKey)
49+
return err == nil && value != "false"
50+
}
51+
4252
// emitEvent 统一处理可选事件回调,调用方无需重复判断 nil。
4353
func emitEvent(onEvent func(string, map[string]interface{}) error, eventType string, payload map[string]interface{}) {
4454
if onEvent == nil {
@@ -385,6 +395,7 @@ func (s *Service) sendMessageInternal(
385395
return nil, err
386396
}
387397
resolvedRoute = route
398+
reasoningContentPassback := s.reasoningContentPassbackEnabled(ctx, input.UserID, route)
388399
if modelChanged || strings.TrimSpace(conversation.Model) != strings.TrimSpace(route.PlatformModelName) {
389400
conversation.Model = strings.TrimSpace(route.PlatformModelName)
390401
conversation.Provider = inferProvider(conversation.Model)
@@ -456,7 +467,7 @@ func (s *Service) sendMessageInternal(
456467
prefetch := <-prefetchCh
457468
contextMessages = s.expandContextMessagesToSnapshotBoundary(ctx, input.ConversationID, userMessage.ID, contextMessages, prefetch.snapshot, compactPolicy)
458469
promptScope := buildPromptScope(contextMessages, prefetch.snapshot, compactPolicy)
459-
promptMessages := s.applyContextTokenBudget(promptScope.activeMessages(), route.UpstreamModel, route.ModelCapabilitiesJSON)
470+
promptMessages := s.applyContextTokenBudget(promptScope.activeMessages(), route.UpstreamModel, route.ModelCapabilitiesJSON, reasoningContentPassback)
460471
ragQuery := buildRAGQuery(promptMessages, input.Content, cfg.RAGQueryHistoryTurns)
461472

462473
conversationFileIDs := collectConversationFileIDs(promptMessages, input.FileIDs)
@@ -476,7 +487,9 @@ func (s *Service) sendMessageInternal(
476487
fileContextPlan := buildConversationFileContextPlan(conversationAttachments, fileMode, cfg, route.UpstreamModel, route.ModelCapabilitiesJSON, capability.RAGAvailable)
477488

478489
// 构建历史消息序列(不含系统注入)
479-
historyMsgs := historyMessagesFromDomain(promptMessages)
490+
historyMsgs := historyMessagesFromDomain(promptMessages, historyMessageOptions{
491+
ReasoningContentPassback: reasoningContentPassback,
492+
})
480493
if len(historyMsgs) == 0 {
481494
historyMsgs = append(historyMsgs, llm.Message{
482495
Role: "user",
@@ -1102,7 +1115,7 @@ func (s *Service) sendMessageInternal(
11021115
break
11031116
}
11041117
reasoningContent := ""
1105-
if route.ReasoningContentPassback {
1118+
if reasoningContentPassback {
11061119
reasoningContent = outputReasoningContent(upstreamOutput)
11071120
}
11081121
llmMessages = append(llmMessages,
@@ -1206,6 +1219,10 @@ func (s *Service) sendMessageInternal(
12061219
retErr = err
12071220
return nil, err
12081221
}
1222+
assistantReasoningContent := ""
1223+
if reasoningContentPassback {
1224+
assistantReasoningContent = outputReasoningContent(upstreamOutput)
1225+
}
12091226
statefulPromptFingerprint := buildPromptStateFingerprint(promptStateFingerprintInput{
12101227
Protocol: route.Protocol,
12111228
Endpoint: routeConfig.Endpoint,
@@ -1214,7 +1231,7 @@ func (s *Service) sendMessageInternal(
12141231
PlatformModelName: conversation.Model,
12151232
ContextConfig: statefulContextConfig,
12161233
ContextState: statefulContextState,
1217-
Messages: buildNextStatefulPrefixMessages(fullLLMMessages, input.Content, assistantText),
1234+
Messages: buildNextStatefulPrefixMessages(fullLLMMessages, input.Content, assistantText, assistantReasoningContent),
12181235
Tools: toolRuntime.definitions,
12191236
Options: filteredOptions,
12201237
})
@@ -1267,6 +1284,7 @@ func (s *Service) sendMessageInternal(
12671284
UserMessage: userMessage,
12681285
AssistantMessage: assistantMessage,
12691286
AssistantText: assistantText,
1287+
AssistantReasoningContent: assistantReasoningContent,
12701288
InputTokens: effectiveInputTokens,
12711289
CacheReadTokens: totalUsage.CacheReadTokens,
12721290
CacheWriteTokens: totalUsage.CacheWriteTokens,

backend/internal/application/conversation/service_prompt_fingerprint.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,16 +209,16 @@ func promptStatePrefixMessages(messages []llm.Message) []llm.Message {
209209
return cloneLLMMessages(messages[:lastUserIndex])
210210
}
211211

212-
func appendAssistantStateMessage(messages []llm.Message, assistantText string) []llm.Message {
212+
func appendAssistantStateMessage(messages []llm.Message, assistantText string, reasoningContent string) []llm.Message {
213213
result := cloneLLMMessages(messages)
214-
result = append(result, llm.Message{Role: "assistant", Content: assistantText})
214+
result = append(result, llm.Message{Role: "assistant", Content: assistantText, ReasoningContent: reasoningContent})
215215
return result
216216
}
217217

218218
// buildNextStatefulPrefixMessages 生成下一轮本地可重建的状态前缀。
219219
// 当前轮真实发送给上游的 user 可能包含文件、RAG、图片等动态 XML;数据库历史只保存用户原文。
220220
// 因此保存 previous_response_id 指纹时使用“稳定前缀 + 用户原文 + 助手回复”,避免下一轮因历史 XML 不可重建而误判失效。
221-
func buildNextStatefulPrefixMessages(messages []llm.Message, currentUserContent string, assistantText string) []llm.Message {
221+
func buildNextStatefulPrefixMessages(messages []llm.Message, currentUserContent string, assistantText string, reasoningContent string) []llm.Message {
222222
lastUserIndex := -1
223223
for index := len(messages) - 1; index >= 0; index-- {
224224
if messages[index].Role == "user" {
@@ -227,11 +227,11 @@ func buildNextStatefulPrefixMessages(messages []llm.Message, currentUserContent
227227
}
228228
}
229229
if lastUserIndex < 0 {
230-
return appendAssistantStateMessage(messages, assistantText)
230+
return appendAssistantStateMessage(messages, assistantText, reasoningContent)
231231
}
232232
result := cloneLLMMessages(messages[:lastUserIndex])
233233
result = append(result, llm.Message{Role: "user", Content: currentUserContent})
234-
result = append(result, llm.Message{Role: "assistant", Content: assistantText})
234+
result = append(result, llm.Message{Role: "assistant", Content: assistantText, ReasoningContent: reasoningContent})
235235
return result
236236
}
237237

backend/internal/application/conversation/service_stateful_responses_test.go

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ func TestPromptStateFingerprintMatchesPrefixAfterAssistantAppend(t *testing.T) {
156156
UpstreamID: 1,
157157
UpstreamModel: "gpt-5.5",
158158
PlatformModelName: "gpt-5.5",
159-
Messages: appendAssistantStateMessage(firstPrompt, "第一轮回答"),
159+
Messages: appendAssistantStateMessage(firstPrompt, "第一轮回答", ""),
160160
Tools: []llm.ToolDefinition{
161161
{Name: "b", Description: "B", InputSchema: []byte(`{"type":"object"}`)},
162162
{Name: "a", Description: "A", InputSchema: []byte(`{"type":"object"}`)},
@@ -186,6 +186,37 @@ func TestPromptStateFingerprintMatchesPrefixAfterAssistantAppend(t *testing.T) {
186186
}
187187
}
188188

189+
func TestPromptStateFingerprintIncludesAssistantReasoning(t *testing.T) {
190+
messages := []llm.Message{
191+
{Role: "user", Content: "第一轮"},
192+
{Role: "assistant", Content: "第一轮回答", ReasoningContent: "推理 A"},
193+
}
194+
195+
left := buildPromptStateFingerprint(promptStateFingerprintInput{Messages: messages})
196+
messages[1].ReasoningContent = "推理 B"
197+
right := buildPromptStateFingerprint(promptStateFingerprintInput{Messages: messages})
198+
199+
if left == right {
200+
t.Fatal("expected reasoning content to affect prompt state fingerprint")
201+
}
202+
}
203+
204+
func TestBuildNextStatefulPrefixMessagesKeepsAssistantReasoning(t *testing.T) {
205+
messages := []llm.Message{
206+
{Role: "system", Content: "policy"},
207+
{Role: "user", Content: "<ctx>dynamic</ctx><q>第一轮</q>"},
208+
}
209+
210+
got := buildNextStatefulPrefixMessages(messages, "第一轮", "第一轮回答", "推理内容")
211+
212+
if len(got) != 3 {
213+
t.Fatalf("expected 3 messages, got %#v", got)
214+
}
215+
if got[2].ReasoningContent != "推理内容" {
216+
t.Fatalf("expected assistant reasoning in state prefix, got %#v", got[2])
217+
}
218+
}
219+
189220
func TestPromptStateFingerprintUsesRebuildableHistoryWhenCurrentUserHasDynamicContext(t *testing.T) {
190221
firstPrompt := []llm.Message{
191222
{Role: "system", Content: "<ctx><files><file name=\"A.md\">稳定文件</file></files></ctx>"},
@@ -198,7 +229,7 @@ func TestPromptStateFingerprintUsesRebuildableHistoryWhenCurrentUserHasDynamicCo
198229
UpstreamID: 1,
199230
UpstreamModel: "gpt-5.5",
200231
PlatformModelName: "gpt-5.5",
201-
Messages: buildNextStatefulPrefixMessages(firstPrompt, "第一轮", "第一轮回答"),
232+
Messages: buildNextStatefulPrefixMessages(firstPrompt, "第一轮", "第一轮回答", ""),
202233
})
203234
secondPrompt := []llm.Message{
204235
{Role: "system", Content: "<ctx><files><file name=\"A.md\">稳定文件</file></files></ctx>"},

backend/internal/application/usersettings/service.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var allowedKeys = map[string]string{
3535
"chat.restore_draft_on_failure": "true",
3636
"chat.preserve_conversation_drafts": "true",
3737
"chat.reuse_model_options": "true",
38+
"chat.reasoning_content_passback": "true",
3839
"chat.input_height": "standard",
3940
"chat.content_width": "compact",
4041
"chat.default_mcp_tool_ids": "[]",
@@ -53,6 +54,7 @@ var boolKeys = map[string]bool{
5354
"chat.restore_draft_on_failure": true,
5455
"chat.preserve_conversation_drafts": true,
5556
"chat.reuse_model_options": true,
57+
"chat.reasoning_content_passback": true,
5658
}
5759

5860
// enumKeys 枚举 key 的合法值集合。

0 commit comments

Comments
 (0)