From f7dad243b6c9a3ca111280df9a9ae31c88f7d231 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 11 Nov 2026 21:38:54 +0800 Subject: [PATCH] =?UTF-8?q?fix(deepseek):=20reasoning=5Fcontent=20?= =?UTF-8?q?=E4=B8=89=E5=B1=82=E8=B4=AF=E9=80=9A=20+=20thinking=20=E5=85=B3?= =?UTF-8?q?=E9=97=AD=E5=B1=82=E7=BA=A7=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 生产故障 all 2 providers failed, last error: api error 400: "The reasoning_content in the thinking mode must be passed back to the API." ## 两个根因,第一个更根本 ### ① thinking 关闭一直没生效(真正的触发器) deepseek.lua 写的是 `req.extra_body.thinking = {type="disabled"}`。 但官方文档里那个 `extra_body={"thinking":...}` 是 **OpenAI SDK 层参数** —— SDK 会把它摊平进 HTTP 请求体顶层。而适配器是**裸发** JSON 给 api.deepseek.com, 多包一层 `extra_body` 服务端根本不认,直接忽略。 ⇒ 即使内核 thinking_enabled=false(发出 disable_thinking=true), 也从来没真正关掉过思考,每次都走 thinking 模式,每次都撞 reasoning_content 规则。 改为顶层 `req.thinking = {type="disabled"}`。 ### ② reasoning 从未被保存和回传 DeepSeek 规则(官方 thinking_mode 指南):请求**带 tools** 时, 所有历史轮的 reasoning_content 都必须传回;内核每轮都带工具定义。 而内核三层全丢: L1 ContextEvent 没有该字段;renderL1Messages 渲染 assistant 时不带 L2 coldcontext 只切 Input/Response 两块,推理内容不进块 L3 蒸馏原文只读 Input/Response/工具输出 修法(按用户裁定:L1/L2/L3 都要包含 reasoning): L1 ContextEvent.ReasoningContent + renderL1Messages 回传 顺带:textForVector 纳入 reasoning —— 关键线索常只在思考里 ("用户要的是 A 不是 B,所以先查 C"),只看 Response 会误裁 L2 RoleReasoning 独立块(不并进 Response:回灌时要放回不同字段, 合并会让思维链被当成正式回复发给模型) L3 coldcontextOriginalText 按**时序** input → reasoning → response ## 一个易错点:不能用 f.Resp.ReasoningContent f.Resp 只在「本批有工具调用」时赋值(stepLLM 里 len(ToolCalls)==0 就 return)。 纯文本收尾轮拿到的是**上一次工具轮**的旧响应 —— 用它写进 L1 会把上一批的 思维链冒名顶替成本轮的(比缺失更坏)。所以单独一个 TurnReasoning, 由 stepLLM 在分支**之前**无条件写入。 ## 适配器兜底(治存量) 内核修了,但**存量会话**的 context.json 是修复前写的,那些事件没这个字段。 不兜底就是死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回, 缺的字段在磁盘上的旧记录里)。 transform_request 里:带 tools 时给每条 assistant 补 reasoning_content="", 已存在则不覆盖(不抹掉真实思考内容)。 ## 顺手修的既有缺陷 零时间戳时 docBlocksForEntry 用 time.Now() 算块 ID,而 ColdEvent.Timestamp 留零值 ⇒ 哈希永久失配 ⇒ 内容就在那里却一个字段都读不回,且不报错不记日志。 线上因为 L1 总带时间戳没暴露。docEventsFromEntries 里先归一化时间戳。 ## 验证 生产实机(A2A 多轮工具调用,日志确认落到 deepseek 源直连): LLM fallback: trying provider "deepseek" response (12186ms, tools=[cmd_run plugin_start cmd_run]) reasoning_content 400 = 0 次 单元判据 16 条,全部变异验证(改回错误形态即红): coldcontext/test/reasoning_contract_test.go 5 条(L2 往返/独立块/ 无思考不造空块/特殊字符/InOut 兼容) agent/api/test/reasoning_protocol_test.go 3 条(顶层字段/无思考不发/ 带 tool_calls 仍发) lua/test/deepseek_reasoning_test.go 8 条(补空串/不覆盖真实值/ 无 tools 不补/补全部轮/不破其他字段/顶层 thinking/默认不加 thinking) go build / vet / gofmt / go test -race 全绿 --- .../agent/api/test/reasoning_protocol_test.go | 129 +++++++++ internal/agent/core/context.go | 87 +++++- internal/agent/core/distill.go | 29 +- internal/agent/core/process.go | 16 +- internal/agent/core/task.go | 39 ++- internal/agent/core/toolcall.go | 14 +- internal/lua/adapters/deepseek.lua | 56 +++- internal/lua/test/deepseek_reasoning_test.go | 261 ++++++++++++++++++ internal/memory/block.go | 22 ++ internal/memory/coldcontext/coldcontext.go | 76 ++++- .../test/reasoning_contract_test.go | 181 ++++++++++++ 11 files changed, 875 insertions(+), 35 deletions(-) create mode 100644 internal/agent/api/test/reasoning_protocol_test.go create mode 100644 internal/lua/test/deepseek_reasoning_test.go create mode 100644 internal/memory/coldcontext/test/reasoning_contract_test.go diff --git a/internal/agent/api/test/reasoning_protocol_test.go b/internal/agent/api/test/reasoning_protocol_test.go new file mode 100644 index 00000000..5a8cd535 --- /dev/null +++ b/internal/agent/api/test/reasoning_protocol_test.go @@ -0,0 +1,129 @@ +package test + +// reasoning_content 的**协议契约**判据(2026-11-11)。 +// +// ## 为什么要单独钉协议层 +// +// 生产故障: +// +// all 2 providers failed, last error: api error 400: +// "The reasoning_content in the thinking mode must be passed back to the API." +// +// DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode): +// +// 请求带 tools 时,**所有历史轮**的 reasoning_content 都要传回,并拼进上下文。 +// +// 而内核每轮都带工具定义,所以这条必然触发。修复横跨三层: +// +// L1 存(ContextEvent.ReasoningContent)+ 渲染回传 +// L2 落块(RoleReasoning 独立块) +// L3 进蒸馏原文 +// +// 本文件**只钉最外面那一层**:内核发给上游的 JSON 里, +// reasoning_content 是不是 assistant 消息的**顶层字段**。 +// +// ## 为什么必须是顶层字段(而不是塞进 content) +// +// OpenAI 兼容格式(DeepSeek 走这条)规定: +// +// {"role":"assistant","content":"回复","reasoning_content":"思考"} +// +// 把思考拼进 content 会同时坏两件事: +// · 上游认为"没回传",照样 400 +// · 模型下一轮会把自己的内心独白读成"我说过这话" +// +// 对照:Anthropic 才是 content block(thinking block)。我们走 OpenAI 兼容, +// 所以是顶层字段 —— 两者不能混。 + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/agent/api" +) + +// 判据:reasoning_content 是 assistant 消息的顶层字段,与 content 平级。 +// +// 变异验证(实测):把 Message.MarshalJSON 里写 reasoning_content 的那段删掉 +// ⇒ 本判据红(字段消失,上游就会 400)。 +func TestReasoningContentIsTopLevelField(t *testing.T) { + msg := api.Message{ + Role: "assistant", + Content: "已查到:上月在 12 号出账", + ReasoningContent: "先确认问的是账单不是订单", + } + raw, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + v, ok := got["reasoning_content"] + if !ok { + t.Fatalf("★ 消息里没有 reasoning_content 顶层字段(上游会 400):%s", raw) + } + if v != "先确认问的是账单不是订单" { + t.Fatalf("★ reasoning_content = %v", v) + } + // content 必须保持原样,不被思考污染。 + if got["content"] != "已查到:上月在 12 号出账" { + t.Fatalf("★ content 被污染:%v", got["content"]) + } +} + +// 判据:无思考时**不发**该字段(不伪造空字段)。 +// +// 为什么:三个层次都要区分「没有思考」与「思考是空的」。 +// 无条件发空串会让每条消息都带一个无意义字段,也会让上游 +// 无从判断"这一轮到底有没有思考过"。 +func TestNoReasoningOmitsField(t *testing.T) { + msg := api.Message{Role: "assistant", Content: "普通回复"} + raw, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if strings.Contains(string(raw), "reasoning_content") { + t.Fatalf("★ 无思考时不该出现该字段:%s", raw) + } +} + +// 判据:带着 tool_calls 的 assistant 消息,reasoning_content 仍必须出现。 +// +// 这条最贴近真实故障形态:DeepSeek 的触发条件是"**带 tools** 的请求历史", +// 而实际报错的具体消息正是 **assistant(tool_calls)** 那条 —— +// 模型先思考、再决定调工具,工具结果回来时那条 assistant 必须带着思考。 +func TestReasoningWithToolCalls(t *testing.T) { + msg := api.Message{ + Role: "assistant", + Content: "", + ReasoningContent: "需要查一下 invoice 表", + ToolCalls: []api.ToolCall{{ + ID: "call_1", + Name: "sql_query", + Arguments: map[string]interface{}{"sql": "select * from invoice"}, + }}, + } + raw, err := json.Marshal(msg) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got["reasoning_content"] != "需要查一下 invoice 表" { + t.Fatalf("★ 带 tool_calls 的 assistant 丢了 reasoning_content:%s", raw) + } + // content 为空的 assistant(tool_calls) 应发 null(OpenAI 语义), + // 不能变成空串 —— 有些上游对两者处理不同。 + if c, ok := got["content"]; !ok || c != nil { + t.Errorf("content 期望 null,实际 %v", c) + } + if _, ok := got["tool_calls"]; !ok { + t.Error("★ tool_calls 丢了") + } +} diff --git a/internal/agent/core/context.go b/internal/agent/core/context.go index d388d2e2..7f9533f2 100644 --- a/internal/agent/core/context.go +++ b/internal/agent/core/context.go @@ -67,13 +67,41 @@ type ContextEvent struct { // // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 - ID string `json:"id,omitempty"` - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Input string `json:"input"` - Response string `json:"response,omitempty"` - ToolsUsed []string `json:"tools_used,omitempty"` - ToolResults []ToolResultItem `json:"tool_results,omitempty"` + ID string `json:"id,omitempty"` + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Input string `json:"input"` + Response string `json:"response,omitempty"` + + // ReasoningContent 是本轮 assistant 的思考内容(thinking 模式的思维链)。 + // + // ## 为什么必须存并必须回传(2026-11-11 生产故障) + // + // 生产报: + // + // all 2 providers failed, last error: api error 400: + // "The reasoning_content in the thinking mode must be passed back to the API." + // + // DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode): + // + // 后续请求是否要回传 reasoning_content,取决于请求是否带 tools: + // 带 tools ⇒ **所有历史轮的 reasoning_content 都要传回**,并会拼进上下文 + // + // 而内核**每一轮都带 tools**(工具定义常驻),所以这条必触发。 + // + // 此前这里没有这个字段:resp.ReasoningContent 收到了(只用于发事件、 + // 显示思考过程),但**没进 L1**,下一轮 renderL1Messages 自然带不上 + // ⇒ 只要对话里出现过一次工具调用 + 思考,整个会话就永久 400 + // (上游多家 agent 项目都踩了同一个坑:langchain #37179、n8n #34924、 + // opencode #24722、codex #24500)。 + // + // 语义边界:这是**协议字段**,不是记忆内容 —— + // · 不向量化、不进 L2/L3 蒸馏(思维链不是事实) + // · 只在渲染 provider 消息时回填 + // · omitempty:存量 context.json 读回来为空串,不伪造 + ReasoningContent string `json:"reasoning_content,omitempty"` + ToolsUsed []string `json:"tools_used,omitempty"` + ToolResults []ToolResultItem `json:"tool_results,omitempty"` // ToolCalls 是本次事件里**每一次**工具调用的完整记录(含参数与配对键)。 // @@ -833,6 +861,34 @@ func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolD text = evt.Input } + // ★ 思维链参与 L1 相关性打分(2026-11-11 用户裁定)。 + // + // 为何应当包含:L1 裁剪是拿**当前输入**给每条历史事件打分,决定哪些 + // 留在窗口里。而关键线索常只在思考里: + // + // 用户其实要的是 A 不是 B ⇒ 所以先查 C ⇒ (回复里只说"已查到")。 + // + // 只看 Response 会把这轮判成"与当前输入无关"而剔掉,下一轮就得重查。 + // 思考内容正是“为什么这条历史相关”的真实依据。 + // + // ★ 边界(为何这与“思维链不进长期记忆”不矛盾): + // + // L1 向量 = 本轮窗口内的取舍打分(临时坐标,Prune 后重算) + // L2/L3 = 长期记忆(coldcontext 只取 Input/Response,见 coldcontext 注释) + // + // 所以思维链可以参与前者,但**不**进后者。冷化时它随事件一起 + // 离开 L1,不会被制成文本块、不被蒸馏成三元组。 + // + // 放长在 Cleaner **之前**:与 Response 同路也过清理(同是模型产出, + // 没有理由一个洗一个不洗)。 + if evt.ReasoningContent != "" { + if text == "" { + text = evt.ReasoningContent + } else { + text = text + "\n" + evt.ReasoningContent + } + } + // 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗) if channelDefLookup != nil { if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil { @@ -1192,13 +1248,16 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, col entries := make([]coldcontext.ContextEntry, len(archive)) for i, s := range archive { entries[i] = coldcontext.ContextEntry{ - Timestamp: s.event.Timestamp, - Source: s.event.Source, - Content: s.event.Input, - Response: s.event.Response, - ToolResults: convertToolResults(s.event.ToolResults), - ToolCalls: toolCallItemsToDoc(s.event.ToolCalls), - Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), + Timestamp: s.event.Timestamp, + Source: s.event.Source, + Content: s.event.Input, + Response: s.event.Response, + // ★ 思考内容必须随事件进 L2(2026-11-11 用户裁定)。 + // 漏了它,事件一旦被裁下,思维链就永久丢失。 + ReasoningContent: s.event.ReasoningContent, + ToolResults: convertToolResults(s.event.ToolResults), + ToolCalls: toolCallItemsToDoc(s.event.ToolCalls), + Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), } } doc, err := coldcontextStore.ContextToColdContext("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) diff --git a/internal/agent/core/distill.go b/internal/agent/core/distill.go index 986e7811..bbd47687 100644 --- a/internal/agent/core/distill.go +++ b/internal/agent/core/distill.go @@ -612,7 +612,12 @@ func isTemplateSummary(s string) bool { return strings.HasPrefix(s, "来自 ") && strings.Contains(s, "条对话") } -func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults []ToolResultItem, toolsUsed []string) { +// reasoning 是本轮模型的思考内容(thinking 模式)。空串表示无思考。 +// +// ★ 为何是参数而不是从 a 上读字段:本轮思考属于**这个 TaskFrame** +// (f.TurnReasoning),不是 agent 的全局状态。agent 级字段在并发任务下 +// 会互相覆盖 —— 而记忆候选一旦写错就是持久化的错。 +func (a *Agent) emitMemoryCandidate(source, input, response, reasoning string, toolResults []ToolResultItem, toolsUsed []string) { payload := map[string]interface{}{ "source": source, "input": input, @@ -622,6 +627,15 @@ func (a *Agent) emitMemoryCandidate(source, input, response string, toolResults "agent_id": string(a.id), "timestamp": time.Now().Unix(), } + // ★ 思考内容也要进记忆候选(2026-11-11 用户裁定:L2/L3 都包含 reasoning)。 + // + // memory_candidate 是**不经 context** 的另一条持久化侧路,所以它不会 + // 从 ContextEvent 那里自动带上 reasoning —— 必须显式放进去。 + // 漏了它,走这条路的消费方拿到的记忆就只有"做了什么",没有"为何"。 + // 空串不放:不向消费方伪造一个空字段。 + if reasoning != "" { + payload["reasoning_content"] = reasoning + } // ★ 记忆候选是**不经 context** 的另一条持久化侧路(走 EmitOutput)。 // 不在这里拦,明文会直接进长期记忆 —— 而记忆跨会话保留、每轮被召回。 a.scrubMemoryCandidate(payload) @@ -657,11 +671,22 @@ func coldcontextOriginalText(coldDoc *coldcontext.ColdDoc) string { } var b strings.Builder for _, ev := range coldDoc.Events { - in, resp, _ := ev.InOut() + // ★ 思考内容必须进蒸馏原文(2026-11-11 用户裁定)。 + // + // 只取 Input/Response 会丢掉决策依据:"排除掉了 B,因为 B 的接口 + // 不返回时间戳"这类判断就只在思考里。丢掉它,蒸馏出的三元组就只剩 + // "选了 A",没有"为何不是 B"——而后者往往才是可复用的知识。 + // + // 顺序按**时序**:输入 → 思考 → 回复。思考发生在回复之前。 + in, resp, reasoning, _ := ev.InOutWithReasoning() if in != "" { b.WriteString(in) b.WriteString("\n") } + if reasoning != "" { + b.WriteString(reasoning) + b.WriteString("\n") + } if resp != "" { b.WriteString(resp) b.WriteString("\n") diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index e9a153b9..7151f958 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -672,7 +672,21 @@ func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message { msgs = append(msgs, agentAPI.Message{Role: "user", Content: input}) } if e.Response != "" || len(calls) > 0 { - msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: e.Response, ToolCalls: calls}) + // ★ ReasoningContent 必须跟着 assistant 消息一起回传(2026-11-11 生产故障)。 + // + // DeepSeek thinking 模式:请求**带 tools** 时,所有历史轮的 + // reasoning_content 都必须传回,否则 400(官方指南 thinking_mode)。 + // 内核每轮都带工具定义,所以这里漏一个字段就会让会话永久不可用。 + // + // omitempty 在 agentAPI.Message 上:空串不发该字段 —— + // 存量事件(本次修复前写入的 context.json)自然无该字段, + // 那种情况交给适配器兜底(见 deepseek.lua 的 transform_request)。 + msgs = append(msgs, agentAPI.Message{ + Role: "assistant", + Content: e.Response, + ReasoningContent: e.ReasoningContent, + ToolCalls: calls, + }) } for _, tr := range e.ToolResults { if tr.CallID != "" && !activeCallIDs[tr.CallID] { diff --git a/internal/agent/core/task.go b/internal/agent/core/task.go index dc75bf18..c66a1a66 100644 --- a/internal/agent/core/task.go +++ b/internal/agent/core/task.go @@ -180,6 +180,18 @@ type TaskFrame struct { CurRaw interface{} Resp *agentAPI.CompletionResponse + // TurnReasoning 是**本次任务里最后一次** LLM 响应的思考内容。 + // + // ★ 为何不能靠 f.Resp.ReasoningContent: + // + // f.Resp 只在“本批有工具调用”时才赋值(stepLLM 里 len(resp.ToolCalls)==0 + // 就 return 了)。纯文本收尾的那一轮 f.Resp 要么是 nil,要么还指着 + // **上一次工具轮**的旧响应 —— 拿它写进 L1 会把上一批的思维链 + // 冒名顶替成本轮的(比缺失更坏:模型会看到张冠李戴的推理)。 + // + // 因此单独一个字段,由 stepLLM **无条件**写入(在分支之前)。 + TurnReasoning string + // Scene 是本轮**涌现**出来的场景键(由场面指纹聚类得到,无人声明), // sceneDone 标记是否已解析过——一轮只解析一次:多解析一次就多给场景 // 加一次强度,「工具调得多」会被误当成「这个场面更常出现」。 @@ -596,14 +608,18 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { // // 声明来源:payload["no_memory"](插件注入)或 ChannelDef.NoMemory(通道)。 turnEvt := ContextEvent{ - Timestamp: time.Now(), - Source: "agent", - NoMemory: f.StageCtx.NoMemory, - Input: f.CleanInput, - Response: f.Response, - ToolsUsed: f.ToolsUsed, - ToolResults: f.ToolResults, - ToolCalls: f.ToolCalls, + Timestamp: time.Now(), + Source: "agent", + NoMemory: f.StageCtx.NoMemory, + Input: f.CleanInput, + Response: f.Response, + // ★ 思维链必须进 L1:下一轮 renderL1Messages 要把它原样回传给上游。 + // DeepSeek thinking 模式在**带 tools 的请求**里强制要求历史 reasoning_content, + // 而内核每轮都带工具定义 ⇒ 不存就永久 400。详见字段注释。 + ReasoningContent: f.TurnReasoning, + ToolsUsed: f.ToolsUsed, + ToolResults: f.ToolResults, + ToolCalls: f.ToolCalls, } a.bindEventMedia(&turnEvt, a.drainMediaBlocks()) // ★ 明文拦截发生在 a.context.Append **内部**(见 context.go 的 Append), @@ -613,7 +629,7 @@ func (a *Agent) finishInputTask(f *TaskFrame, out stepOutcome) { a.emitResponse(evt, f.Response, f.turnUsage) if !f.StageCtx.NoMemory { - a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.ToolResults, f.ToolsUsed) + a.emitMemoryCandidate(evt.Source, f.CleanInput, f.Response, f.TurnReasoning, f.ToolResults, f.ToolsUsed) } f.Terminal = terminalOK } @@ -910,6 +926,11 @@ func (a *Agent) stepLLM(f *TaskFrame) stepOutcome { }) } + // ★ 必须放在分支**之前**:下一行的 len(ToolCalls)==0 会直接 return, + // 那条路径也要记下 reasoning(否则纯文本收尾轮的思维链丢失,而它同样 + // 属于“带 tools 的请求历史”,DeepSeek 照样要它)。 + f.TurnReasoning = resp.ReasoningContent + if len(resp.ToolCalls) == 0 { f.Response = resp.Content return outcomeDone diff --git a/internal/agent/core/toolcall.go b/internal/agent/core/toolcall.go index 3f6858df..be1a2a36 100644 --- a/internal/agent/core/toolcall.go +++ b/internal/agent/core/toolcall.go @@ -1464,12 +1464,16 @@ func (a *Agent) restoreDocEvents(d *coldcontext.ColdDoc, inactivePages []int) in n, blocks := 0, 0 for _, ev := range d.Events { - input, response, held := ev.InOut() + // ★ 用 InOutWithReasoning:回灌要完整还原事件,包括思考内容。 + // 只用 InOut 会让回填出的 ContextEvent 缺 ReasoningContent, + // 于是再次被裁下时思维链就永久丢了(而且 L1 里那份已经是唯一副本)。 + input, response, reasoning, held := ev.InOutWithReasoning() a.context.InsertByTimestamp(ContextEvent{ - Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置 - Source: ev.Source, - Input: input, - Response: response, + Timestamp: ev.Timestamp, // ★ 原始时间戳:回到事件发生的位置 + Source: ev.Source, + Input: input, + Response: response, + ReasoningContent: reasoning, // ★ 回填事件**不参与记忆计算**,但**照常参与归档**。 // // 为什么要标记:它们的内容刚在 L2 里被向量化过一次, diff --git a/internal/lua/adapters/deepseek.lua b/internal/lua/adapters/deepseek.lua index 6ec2127f..aa9c489f 100644 --- a/internal/lua/adapters/deepseek.lua +++ b/internal/lua/adapters/deepseek.lua @@ -56,11 +56,63 @@ function adapter.transform_request(raw_body) req.model = req.model or "deepseek-chat" req.stream = req.stream or false + + -- ── thinking 开关:必须是**顶层**字段,不能包在 extra_body 里 ── + -- + -- 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}} + -- 那是 **SDK 层参数** —— SDK 会把它摊平进 HTTP 请求体顶层, + -- 真实发出去的是 {"thinking": {...}}。而本适配器是把 JSON **裸发**给 + -- api.deepseek.com 的,再包一层 extra_body 服务端不认识,直接忽略 + -- ⇒ thinking 保持默认(**开启**)⇒ 带 tools 的请求就要求回传 + -- reasoning_content,否则 400。 + -- + -- 修正前写的是 req.extra_body.thinking,所以即使内核 + -- thinking_enabled=false(发出 disable_thinking=true)也从未真正关掉思考。 + -- + -- 为何值得关:deepseek-flash 的 thinking 默认开启,显式关闭省掉整段 CoT + -- 的 token;若用户确实要思考,就不会配 thinking_enabled=false。 if req.disable_thinking then - req.extra_body = req.extra_body or {} - req.extra_body.thinking = { type = "disabled" } + req.thinking = { type = "disabled" } end req.disable_thinking = nil + -- 内核从不发 extra_body;万一将来发了,也不该把 SDK 层的壳透传给上游。 + req.extra_body = nil + + -- ── reasoning_content 回传兜底(2026-11-11 生产故障)── + -- + -- 上游报: + -- all 2 providers failed, last error: api error 400: + -- "The reasoning_content in the thinking mode must be passed back to the API." + -- + -- DeepSeek 官方规则(api-docs.deepseek.com/guides/thinking_mode): + -- 请求**带 tools** 时,所有历史轮的 reasoning_content 都必须传回, + -- 并会拼进上下文;不带 tools 时不要求。 + -- + -- 内核侧已修(ContextEvent.ReasoningContent + renderL1Messages 回传), + -- 但**存量会话**的 context.json 是在修复前写的,那些事件根本没这个字段。 + -- 不补就是个死结:历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来, + -- 因为缺的字段在磁盘上的旧记录里)。 + -- + -- 修法:给**每一条 assistant 消息**补齐该字段(缺失时补空串)。 + -- 为什么空串是合法的:字段的语义是"上一轮的思考内容",空串如实表达 + -- "这一轮没有可回传的思考"(其他主流实现的同款修复也是补空串: + -- langchain #37179、n8n #34924、opencode #24722)。 + -- + -- 只给 assistant 补:system/user/tool 消息不接受该字段,乱发可能被拒。 + if type(req.messages) == "table" then + local has_tools = type(req.tools) == "table" and #req.tools > 0 + for _, m in ipairs(req.messages) do + if type(m) == "table" and m.role == "assistant" and m.reasoning_content == nil then + -- 只在带 tools 的请求里补(无 tools 时上游不要求,也不想平白 + -- 往上下文里塞一个空字段)。但如果模型名显式是 reasoner, + -- 它可能无条件校验,所以两条都补。 + if has_tools or string.find(tostring(req.model), "reasoner", 1, true) ~= nil then + m.reasoning_content = "" + end + end + end + end + return json.encode(req) end diff --git a/internal/lua/test/deepseek_reasoning_test.go b/internal/lua/test/deepseek_reasoning_test.go new file mode 100644 index 00000000..2bd2cdbb --- /dev/null +++ b/internal/lua/test/deepseek_reasoning_test.go @@ -0,0 +1,261 @@ +package test + +// deepseek 适配器的 reasoning_content 回传判据(2026-11-11)。 +// +// ## 钉的故障 +// +// all 2 providers failed, last error: api error 400: +// "The reasoning_content in the thinking mode must be passed back to the API." +// +// DeepSeek 规则:请求带 tools 时,所有历史轮的 reasoning_content 都要传回。 +// +// 内核侧三层已修(L1 存+回传、L2 落块、L3 进蒸馏),但**存量会话**的 +// context.json 是修复前写的,那些历史事件根本没有该字段。不兜底就是死结: +// 历史缺字段 ⇒ 400 ⇒ 会话永久不可用(重启也救不回来,缺的字段在磁盘上)。 +// +// 所以适配器必须补字段。本文件钉住"兜底补得对、且不覆盖真实值"。 +// +// ## 为什么要补**空串**而不是跳过 +// +// 字段语义是"上一轮的思考内容"。空串如实表达"这一轮没有可回传的思考"。 +// 其他主流实现的同款修复也是补空串(langchain #37179、n8n #34924、 +// opencode #24722)—— 上游接受,且比删掉整条消息更保真。 + +import ( + "encoding/json" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/lua" +) + +// newStartedVM 起一个加载了内置适配器的 VM(与运行时同一套落盘逻辑)。 +func newStartedVM(t *testing.T) *lua.VM { + t.Helper() + v := lua.NewVM(t.TempDir()) + if err := v.Start(); err != nil { + t.Fatalf("VM.Start: %v", err) + } + t.Cleanup(v.Stop) + return v +} + +// transform 调真实内置适配器的 transform_request,返回解码后的请求体。 +func transform(t *testing.T, v *lua.VM, body string) map[string]interface{} { + t.Helper() + out, err := v.CallTransformRequest("deepseek", body) + if err != nil { + t.Fatalf("CallTransformRequest: %v", err) + } + var got map[string]interface{} + if err := json.Unmarshal([]byte(out), &got); err != nil { + t.Fatalf("适配器输出不是合法 JSON: %v\n%s", err, out) + } + return got +} + +// assistantMsgs 取请求里所有 assistant 消息。 +func assistantMsgs(req map[string]interface{}) []map[string]interface{} { + var out []map[string]interface{} + msgs, _ := req["messages"].([]interface{}) + for _, m := range msgs { + if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "assistant" { + out = append(out, mm) + } + } + return out +} + +// 判据:带 tools + assistant 缺 reasoning_content ⇒ 必须补上空串。 +// +// 变异验证(实测):把 transform_request 里补字段那段删掉 ⇒ 本判据红。 +func TestAdapterBackfillsReasoningWhenToolsPresent(t *testing.T) { + v := newStartedVM(t) + // 存量会话形态:assistant(tool_calls) 没有 reasoning_content + body := `{ + "model":"deepseek-chat", + "tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}], + "messages":[ + {"role":"user","content":"上个月账单什么时候出的?"}, + {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"sql_query","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"12号"} + ] + }` + + got := transform(t, v, body) + as := assistantMsgs(got) + if len(as) != 1 { + t.Fatalf("★ assistant 消息数 = %d,期望 1", len(as)) + } + if _, ok := as[0]["reasoning_content"]; !ok { + t.Fatalf("★ 带 tools 的请求里 assistant 缺 reasoning_content(上游会 400):%v", as[0]) + } + // 补的是空串,不是凭空造内容 + if s, _ := as[0]["reasoning_content"].(string); s != "" { + t.Errorf("★ 兜底应补空串,实际 %q(不得伪造思考内容)", s) + } +} + +// 判据:**不能覆盖**内核已经带来的真实思考内容。 +// +// 这条比上面那条更要紧:覆盖 = 把真实思维链换成空串, +// 上游虽然不报 400 了,但多轮上下文里的推理链条被抹掉, +// 属于"错误从显式变隐式"。 +func TestAdapterDoesNotOverwriteRealReasoning(t *testing.T) { + v := newStartedVM(t) + const realThinking = "先确认问的是账单不是订单" + body := `{ + "model":"deepseek-chat", + "tools":[{"type":"function","function":{"name":"sql_query","parameters":{"type":"object"}}}], + "messages":[ + {"role":"user","content":"问"}, + {"role":"assistant","content":"答","reasoning_content":"` + realThinking + `"} + ] + }` + + got := transform(t, v, body) + as := assistantMsgs(got) + if len(as) != 1 { + t.Fatalf("assistant 数 = %d", len(as)) + } + if s, _ := as[0]["reasoning_content"].(string); s != realThinking { + t.Fatalf("★ 真实思考被覆盖:%q(期望 %q)", s, realThinking) + } +} + +// 判据:不带 tools 时不补(上游不要求,也不必给上下文塞空字段)。 +func TestAdapterLeavesReasoningAloneWithoutTools(t *testing.T) { + v := newStartedVM(t) + body := `{ + "model":"deepseek-chat", + "messages":[ + {"role":"user","content":"你好"}, + {"role":"assistant","content":"你好,有什么可以帮你"} + ] + }` + + got := transform(t, v, body) + as := assistantMsgs(got) + if len(as) != 1 { + t.Fatalf("assistant 数 = %d", len(as)) + } + if _, ok := as[0]["reasoning_content"]; ok { + t.Fatalf("★ 无 tools 时不该补该字段:%v", as[0]) + } +} + +// 判据:多轮历史里**每一条** assistant 都要有,不能只补最后一条。 +// +// DeepSeek 的要求是"所有历史轮"。只补最后一条会让第二次工具回环 +// 再次 400 —— 而那种"修一次好一次、第三轮又坏"最难查。 +func TestAdapterBackfillsEveryAssistantTurn(t *testing.T) { + v := newStartedVM(t) + body := `{ + "model":"deepseek-chat", + "tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}], + "messages":[ + {"role":"user","content":"a"}, + {"role":"assistant","content":"","tool_calls":[{"id":"c1","type":"function","function":{"name":"t","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c1","content":"r1"}, + {"role":"user","content":"b"}, + {"role":"assistant","content":"","tool_calls":[{"id":"c2","type":"function","function":{"name":"t","arguments":"{}"}}]}, + {"role":"tool","tool_call_id":"c2","content":"r2"} + ] + }` + + got := transform(t, v, body) + as := assistantMsgs(got) + if len(as) != 2 { + t.Fatalf("assistant 数 = %d,期望 2", len(as)) + } + for i, m := range as { + if _, ok := m["reasoning_content"]; !ok { + t.Errorf("★ 第 %d 条 assistant 缺 reasoning_content(要补全部历史轮)", i+1) + } + } +} + +// 判据:适配器不能顺手弄坏别的字段(tools / messages / model)。 +// +// 反向保护:兜底逻辑若写成"重建整个请求体",很容易漏字段。 +// 这条钉住"只补一个字段,其余原样"。 +func TestAdapterPreservesOtherFields(t *testing.T) { + v := newStartedVM(t) + body := `{ + "model":"deepseek-chat", + "temperature":0.7, + "max_tokens":1024, + "stream":true, + "tools":[{"type":"function","function":{"name":"t","parameters":{"type":"object"}}}], + "messages":[{"role":"user","content":"hi"}] + }` + + got := transform(t, v, body) + if got["model"] != "deepseek-chat" { + t.Errorf("★ model 被改:%v", got["model"]) + } + if temp, _ := got["temperature"].(float64); temp != 0.7 { + t.Errorf("★ temperature 被改:%v", got["temperature"]) + } + if mt, _ := got["max_tokens"].(float64); mt != 1024 { + t.Errorf("★ max_tokens 被改:%v", got["max_tokens"]) + } + if got["stream"] != true { + t.Errorf("★ stream 被改:%v", got["stream"]) + } + if _, ok := got["tools"]; !ok { + t.Error("★ tools 丢了") + } + // 无思考内容的 user 消息不该被加上该字段 + msgs, _ := got["messages"].([]interface{}) + for _, m := range msgs { + if mm, ok := m.(map[string]interface{}); ok && mm["role"] == "user" { + if _, has := mm["reasoning_content"]; has { + t.Errorf("★ 给 user 消息加了 reasoning_content(只该给 assistant)") + } + } + } +} + +// 判据:disable_thinking 必须转成**顶层** thinking 字段。 +// +// ## 为什么这条最关键(它是 400 的第一根因) +// +// 官方文档示例全用 OpenAI SDK:extra_body={"thinking": {...}}。 +// 那是 **SDK 层参数** —— SDK 会摊平进请求体顶层。而适配器是裸发 HTTP, +// 修正前写成 req.extra_body.thinking,服务端不认识这个包,直接忽略 +// ⇒ thinking 保持默认(开启)⇒ 带 tools 的请求强制要求 reasoning_content +// ⇒ 400。也就是说:配置 thinking_enabled=false 也从未真正关掉过思考。 +// +// 变异验证(实测):把 req.thinking 改回 req.extra_body.thinking ⇒ 本判据红。 +func TestAdapterDisablesThinkingAtTopLevel(t *testing.T) { + v := newStartedVM(t) + got := transform(t, v, `{"model":"deepseek-chat","disable_thinking":true,"messages":[{"role":"user","content":"x"}]}`) + + if _, ok := got["disable_thinking"]; ok { + t.Error("★ disable_thinking 应被消费掉,不该透传给上游") + } + // 必须是顶层 + th, ok := got["thinking"].(map[string]interface{}) + if !ok { + t.Fatalf("★ 缺少顶层 thinking 字段(上游会忽略 ⇒ 思考关不掉):%v", got) + } + if th["type"] != "disabled" { + t.Fatalf("★ thinking.type = %v,期望 disabled", th["type"]) + } + // 不得留 extra_body 壳(SDK 层的参数名,裸 HTTP 上游不认识) + if _, ok := got["extra_body"]; ok { + t.Error("★ 不应把 extra_body 壳透传给上游(SDK 层参数)") + } +} + +// 判据:不带 disable_thinking 时,不得凭空加 thinking 字段。 +// +// 反向保护:默认不改上游行为。上游默认是**开启**思考, +// 我们只在用户明确要求关闭时才动。 +func TestAdapterNoThinkingFieldByDefault(t *testing.T) { + v := newStartedVM(t) + got := transform(t, v, `{"model":"deepseek-chat","messages":[{"role":"user","content":"x"}]}`) + if _, ok := got["thinking"]; ok { + t.Fatalf("★ 未要求关闭时不该加 thinking:%v", got["thinking"]) + } +} diff --git a/internal/memory/block.go b/internal/memory/block.go index ad162130..f5f94282 100644 --- a/internal/memory/block.go +++ b/internal/memory/block.go @@ -48,6 +48,28 @@ const ( RoleRequest BlockRole = "request" // RoleResponse 是页的响应端:llm 的 output,或 tool 的 result。 RoleResponse BlockRole = "response" + // RoleReasoning 是 llm_round 页里的**思考内容**(thinking 模式的思维链)。 + // + // ## 为何它必须是独立一块,而不是拼进 RoleResponse + // + // 两者在协议层是**不同的字段**: + // + // assistant.content ← 模型交付的回复 + // assistant.reasoning_content ← 模型的思考过程 + // + // 拼在一起会有两个硬伤: + // · 回灌时无法区分 —— 思维链会被当成正式回复发给模型(模型会把自己的 + // 内心独白读成"我说过这话") + // · 各自语义不同,合成一块只能有一个向量,检索时无法单独命中"为什么" + // + // ## 为何 L2/L3 也要它(2026-11-11 用户裁定) + // + // 此前 L2 只落 Input/Response、L3 蒸馏也不带思考 —— 那是错的: + // 决策依据、排除掉的选项、"先查 A 再查 B"的因果链,常常**只**在思考里。 + // 丢掉它,召回时就只剩"我做了 X",答不出"为何是 X 而不是 Y"。 + // + // 与 L1 的取舍同理:思考是上下文的一部分,不是噪声。 + RoleReasoning BlockRole = "reasoning" ) // MemoryBlock 是 Context、Document、Graph 三层共同使用的记忆块值。 diff --git a/internal/memory/coldcontext/coldcontext.go b/internal/memory/coldcontext/coldcontext.go index 6afc7875..dfc4727a 100644 --- a/internal/memory/coldcontext/coldcontext.go +++ b/internal/memory/coldcontext/coldcontext.go @@ -808,6 +808,12 @@ type ContextEntry struct { Source string Content string Response string + // ReasoningContent 是本轮模型的思考内容(thinking 模式的思维链)。 + // + // L2 必须存它(2026-11-11 用户裁定):决策依据、排除掉的选项、 + // "先查 A 再查 B"的因果链常常只在思考里。只存 Response 会导致 + // 召回后只能答出"做了什么",答不出"为何这么做"。 + ReasoningContent string // ToolCalls 是工具调用的完整记录(含参数),归档时原样带到 L2。 ToolCalls []ToolCallItem ToolsUsed []string @@ -962,6 +968,12 @@ func entryTextBlockID(evtTS time.Time, source, role, text string) string { const ( TextBlockRoleInput = "input" TextBlockRoleResponse = "response" + // TextBlockRoleReasoning 标记 llm_round 页里的**思考内容**块。 + // + // 单独一类(而不是并进 response)的理由:回灌时要把它们放回不同字段 + // (ContextEvent.ReasoningContent vs .Response)。合并后回灌会把思维链 + // 当成正式回复发给模型。 + TextBlockRoleReasoning = "reasoning" ) // docBlocksForEntry 把一条上下文事件落成「文本块 + 媒体块」的块列表。 @@ -1027,6 +1039,29 @@ func docBlocksForEntry(e ContextEntry) []memory.MemoryBlock { UpdatedAt: now, }) } + // 思考块:与 Response 同属 llm_round 第 1 页,但**单独一块、独立向量**。 + // + // 为何必须落进 coldcontext(2026-11-11 用户裁定): + // 决策依据、排除掉的选项、"先查 A 再查 B"的因果链常常只在思考里。 + // 只存 Response ⇒ 召回后只能答出"做了什么",答不出"为何这么做"。 + // + // 为何不并进上面的 Response 块:回灌时两者要放回**不同字段** + // (ContextEvent.ReasoningContent vs .Response);合并后回灌会把 + // 思维链当成正式回复发给模型。 + if t := strings.TrimSpace(e.ReasoningContent); t != "" { + out = append(out, memory.MemoryBlock{ + ID: entryTextBlockID(now, e.Source, TextBlockRoleReasoning, t), + Modality: memory.BlockText, + Text: t, + Source: e.Source, + FrameTS: frameTS, + PageSeq: 1, + PageKind: memory.PageLLMRound, + BlockRole: memory.RoleReasoning, + CreatedAt: now, + UpdatedAt: now, + }) + } // 工具轮:每个 toolcall 与它的 result 构成一页。 // @@ -1149,8 +1184,18 @@ func entryToolBlockID(evtTS time.Time, source string, page int, role memory.Bloc // 识别规则:文本块按 ID 前缀里的角色区分(Input 在前、Response 在后), // 其余块(媒体/未知来源的文本块)原样作为「事件持有的块」返回。 func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryBlock) { + in, resp, _, h := blocksToEntryWithReasoning(ev) + return in, resp, h +} + +// blocksToEntryWithReasoning 是 blocksToEntry 的完整形态,额外还原思考内容。 +// +// 为何不直接改 blocksToEntry 的签名:它被 8 个调用点使用,其中只有 +// 回灌(restoreDocEvents)与 L3 蒸馏需要 reasoning。改签名会让 6 个 +// 不需要它的调用点跟着改——那种改动会淹没真正的语义变化。 +func blocksToEntryWithReasoning(ev *ColdEvent) (input, response, reasoning string, held []memory.MemoryBlock) { if ev == nil { - return "", "", nil + return "", "", "", nil } for i := range ev.Blocks { b := ev.Blocks[i] @@ -1163,6 +1208,8 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB input = b.Text case response == "" && isEntryTextBlock(b, ev, TextBlockRoleResponse): response = b.Text + case reasoning == "" && isEntryTextBlock(b, ev, TextBlockRoleReasoning): + reasoning = b.Text case b.PageKind == memory.PageToolRound: // ★ 工具页的块**不是**事件的"持有块",而是 ToolCalls/ToolResults // 的页级表示(P2 派生出来的)。若把它当持有块塞回去, @@ -1176,7 +1223,7 @@ func blocksToEntry(ev *ColdEvent) (input, response string, held []memory.MemoryB held = append(held, b) } } - return input, response, held + return input, response, reasoning, held } // isEntryTextBlock 判断文本块是否为本事件按该角色派生出来的那一块。 @@ -1191,6 +1238,18 @@ func docEventsFromEntries(entries []ContextEntry) []ColdEvent { } out := make([]ColdEvent, 0, len(entries)) for _, e := range entries { + // ★ 时间戳先归一化,否则块 ID 与事件时间会失配(既有缺陷,2026-11-11 修)。 + // + // 成因:docBlocksForEntry 内部的 now 在 e.Timestamp 为零时退化成 time.Now(), + // 而 ColdEvent.Timestamp 保持零值 ⇒ 块 ID 是基于“现在”算的, + // 回读却拿零值时间重算 ⇒ 哈希不等 ⇒ isEntryTextBlock 永远不认, + // 表现为“块就在那里但一个字段也读不回来”。 + // + // 线上因为 L1 事件总是带 Timestamp 而没暴露,但它是真缺陷: + // 任何入口漏填时间戳,事件内容就静默丢失(不报错、不掍日志)。 + if e.Timestamp.IsZero() { + e.Timestamp = time.Now() + } ev := ColdEvent{ Timestamp: e.Timestamp, Source: e.Source, @@ -1259,3 +1318,16 @@ func contentFromEvents(events []ColdEvent) string { func (ev *ColdEvent) InOut() (input, response string, held []memory.MemoryBlock) { return blocksToEntry(ev) } + +// InOutWithReasoning 与 InOut 相同,但额外读回思考内容。 +// +// 用在哪里:需要把事件**完整还原**成 ContextEvent 的地方(L2 回灌、L3 蒸馏)。 +// 这些地方如果只拿 InOut,回填出的事件就没有 ReasoningContent, +// 于是再次裁下时思维链就永久丢了。 +// +// 为何不把 InOut 直接改成多返回值:它被 8 个调用点用,而其中只有 2 处 +// 需要思考内容。让另外 6 处跟着改,只会把"哪些地方真的关心思考" +// 这个信号淹没在一堆样板修改里。 +func (ev *ColdEvent) InOutWithReasoning() (input, response, reasoning string, held []memory.MemoryBlock) { + return blocksToEntryWithReasoning(ev) +} diff --git a/internal/memory/coldcontext/test/reasoning_contract_test.go b/internal/memory/coldcontext/test/reasoning_contract_test.go new file mode 100644 index 00000000..86e54a8e --- /dev/null +++ b/internal/memory/coldcontext/test/reasoning_contract_test.go @@ -0,0 +1,181 @@ +package test + +// 思考内容(reasoning)在 L2/L3 的往返判据(2026-11-11)。 +// +// ## 钉的是什么 +// +// 用户裁定:**L1/L2/L3 都要包含 reasoning**。此前 L2 只落 Input/Response、 +// L3 蒸馏只读 Input/Response/工具输出 —— 那是错误实现。 +// +// 真因(生产 400): +// +// all 2 providers failed, last error: api error 400: +// "The reasoning_content in the thinking mode must be passed back to the API." +// +// DeepSeek 规则:请求**带 tools** 时,所有历史轮的 reasoning_content 都要回传。 +// 内核每轮都带工具定义 ⇒ 一旦历史里丢了思考内容,会话就永久 400。 +// +// ## 判据走的是真实入口 +// +// 用 ContextToColdContext(L1 → L2 的唯一入口)与 InOutWithReasoning +// (L2 → L1 的还原入口),断言"写进去的思考能不能原样读回来"。 +// 不测内部函数、不碰私有字段。 + +import ( + "strings" + "testing" + + "github.com/JianFeeeee/HologramAgent/internal/memory/coldcontext" +) + +// 判据:思考内容随事件进 L2,并能从块里读回。 +// +// 变异验证(实测):把 docBlocksForEntry 里生成 reasoning 块的那段删掉 +// ⇒ 本判据红(reasoning 读回为空)。 +func TestReasoningSurvivesL2RoundTrip(t *testing.T) { + s := newStore(t) + + const thinking = "先确认用户问的是账单不是订单,再查 invoice 表" + const reply = "已查到:上月在 12 号出账" + + doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{ + Source: "agent", + Content: "上个月账单什么时候出的?", + Response: reply, + ReasoningContent: thinking, + }}, nil, nil, nil, nil) + if err != nil { + t.Fatalf("ContextToColdContext: %v", err) + } + if doc == nil { + t.Fatal("★ ContextToColdContext 返回 nil") + } + if len(doc.Events) != 1 { + t.Fatalf("★ 事件数 = %d,期望 1", len(doc.Events)) + } + + in, resp, reasoning, _ := doc.Events[0].InOutWithReasoning() + if reasoning != thinking { + t.Fatalf("★ L2 丢了思考内容:读回 %q,期望 %q", reasoning, thinking) + } + // 顺带钉住"没串味":三者在 L2 里必须是三个独立的东西, + // 否则回灌时会把思维链当成正式回复发给模型。 + if in != "上个月账单什么时候出的?" { + t.Errorf("Input 串味:%q", in) + } + if resp != reply { + t.Errorf("Response 串味:%q", resp) + } +} + +// 判据:思考块与回复块是**两个块**,不是合并成一个。 +// +// 为什么单独钉:合并会让回灌时分不清哪个是 content、哪个是 reasoning, +// 而这个错误在"内容还在、只是位置不对"的形态下不会触发任何 panic。 +func TestReasoningIsSeparateBlockFromResponse(t *testing.T) { + s := newStore(t) + + doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{ + Source: "agent", + Content: "问题", + Response: "回复", + ReasoningContent: "思考", + }}, nil, nil, nil, nil) + if err != nil || doc == nil { + t.Fatalf("ContextToColdContext: %v / %v", err, doc) + } + + var reasoningBlocks, responseBlocks int + for _, b := range doc.Events[0].Blocks { + switch string(b.BlockRole) { + case string(coldcontext.TextBlockRoleReasoning): + reasoningBlocks++ + case string(coldcontext.TextBlockRoleResponse): + responseBlocks++ + } + } + if reasoningBlocks != 1 { + t.Errorf("★ 思考块数 = %d,期望 1(合并进 response 会让回灌串味)", reasoningBlocks) + } + if responseBlocks != 1 { + t.Errorf("★ 回复块数 = %d,期望 1", responseBlocks) + } +} + +// 判据:没有思考时不得凭空造一个空块。 +// +// 反向保护:如果实现改成"无条件生成 reasoning 块",上面两条仍会绿, +// 但每次归档都会多一个空块 —— L2 里堆满噪声,且空块的 ID 会随 +// 时间戳变化而产生大量无效条目。 +func TestNoReasoningMeansNoBlock(t *testing.T) { + s := newStore(t) + + doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{ + Source: "agent", + Content: "问题", + Response: "回复", + }}, nil, nil, nil, nil) + if err != nil || doc == nil { + t.Fatalf("ContextToColdContext: %v / %v", err, doc) + } + for _, b := range doc.Events[0].Blocks { + if string(b.BlockRole) == string(coldcontext.TextBlockRoleReasoning) { + t.Fatal("★ 无思考时不应生成 reasoning 块") + } + } + _, _, reasoning, _ := doc.Events[0].InOutWithReasoning() + if reasoning != "" { + t.Fatalf("★ 无思考时读回 %q,期望空串", reasoning) + } +} + +// 判据:思考里带特殊字符(换行、引号、中文标点)也能原样往返。 +// +// 为什么值得钉:块 ID 由 sha256(时间戳|source|role|text) 派生, +// 而 L2 落盘是 JSON。任何一处转义或截断都会让 ID 与内容不匹配, +// 表现为"块存在但读不回来"——这正是 isEntryTextBlock 的判据会失配的地方。 +func TestReasoningRoundTripWithSpecialChars(t *testing.T) { + s := newStore(t) + thinking := "第一行:检查 \"invoice\" 表\n第二行:注意 NULL 值\n第三行:结论「需要重试」" + + doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{ + Source: "agent", + Content: "查一下", + Response: "好", + ReasoningContent: thinking, + }}, nil, nil, nil, nil) + if err != nil || doc == nil { + t.Fatalf("ContextToColdContext: %v / %v", err, doc) + } + _, _, got, _ := doc.Events[0].InOutWithReasoning() + if got != thinking { + t.Fatalf("★ 特殊字符往返失真:\n got=%q\nwant=%q", got, thinking) + } +} + +// 判据:InOut 保持三返回值(不因新增 reasoning 而破坏既有调用方)。 +// +// 这条是**兼容性契约**:InOut 有 8 个调用点,其中 6 个不需要思考内容。 +// 如果实现图省事把 InOut 改成四返回值,那些调用点会被迫一起改 —— +// 改动量会掩盖真正的语义变化,且容易改错。 +func TestInOutSignatureStillUsable(t *testing.T) { + s := newStore(t) + doc, err := s.ContextToColdContext("agent", []coldcontext.ContextEntry{{ + Source: "agent", + Content: "问", + Response: "答", + ReasoningContent: "思", + }}, nil, nil, nil, nil) + if err != nil || doc == nil { + t.Fatalf("ContextToColdContext: %v / %v", err, doc) + } + // 三返回值必须仍然可用,且语义不变。 + in, resp, held := doc.Events[0].InOut() + if in != "问" || resp != "答" { + t.Fatalf("★ InOut 语义变了:in=%q resp=%q", in, resp) + } + _ = held // 无媒体块时为 nil + if strings.Contains(resp, "思") { + t.Fatal("★ InOut 的 response 里混进了思考内容") + } +}