fix(deepseek): reasoning_content 三层贯通 + thinking 关闭层级修正

## 生产故障

    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  全绿
This commit is contained in:
JianFeeeee
2026-11-11 21:38:54 +08:00
parent 319fa91ce7
commit f7dad243b6
11 changed files with 875 additions and 35 deletions

View File

@ -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 丢了")
}
}

View File

@ -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())

View File

@ -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")

View File

@ -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] {

View File

@ -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

View File

@ -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 里被向量化过一次,

View File

@ -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

View File

@ -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"])
}
}

View File

@ -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 三层共同使用的记忆块值。

View File

@ -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)
}

View File

@ -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 里混进了思考内容")
}
}