From 24b03e56e9aeb0578bd521dbc4bdcfb0e4af2714 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 3 Nov 2026 14:37:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20L1=20=E6=81=A2=E5=A4=8D=E4=B8=BA?= =?UTF-8?q?=20provider=20=E5=8E=9F=E7=94=9F=E6=B6=88=E6=81=AF=E5=BA=8F?= =?UTF-8?q?=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 设计口径(用户裁定,2026-11-03) 「我的设计是 l1 渲染出来就是传统式的上下文排列, 难怪指代消解能力这么差」 ## 之前错在哪 buildMessages 把**全部历史事件拼成一条 system 消息**: system: 系统提示词 system: [Conversation timeline] [15:04:05] 用户: 帮我找月租<400的房子 [15:04:05] 助手: ... [15:04:05] 工具(qq_get_message): ... user: 当前输入 说话人身份靠**文本前缀**表达。模型对它拿到的是"背景资料"式的注意力 结构,而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上 上一句"帮我找月租<400…")。 ★ 0f98f36 那次只改了**文本渲染格式**(把日志样式换成对话样式), 底层"一条 system 承载全部历史"的结构一动没动,所以问题还在。 ## 现在的形态 system 系统提示词 user 帮我找一套月租<400 水电常价带独卫的单间 assistant 好的,我去找。 user 新乡 assistant 新乡这边有几套。 user 当前输入 工具轮按协议展开: user 帮我看看天气 assistant (content + tool_calls=[c1,c2]) tool (tool_call_id=c1) 晴 26 度 tool (tool_call_id=c2) 他说新乡 user 继续 实现要点: - 新增 renderL1Messages:一个 ContextEvent 展开为 user → assistant(calls) → tool... 消息; - tool_call 与 tool 按 CallID 严格配对(CallID 优先,缺失退化到工具名); - InactivePages 剔除**完整的工具页**:assistant 上对应的 tool_call 与 紧随的 tool 消息一起消失,绝不留下"有 call 无 result"(上游必 400); - 超长工具结果仍渲染为句柄(toolresult_read 取回); - 保留 timelineUserText 的中断轮处理:从聊天工具回执里取真实原话, 不让注入样板冒充用户发言; - 保留 token 预算裁剪,事件级按消息粒度计入。 ## 清理(无生产调用方的旧渲染路径) 删除: - formatMergedTimeline(旧 system-timeline 唯一生产者) - renderedToolResultsOf(旧截断风格渲染,已被原生序列取代) - timelineToolLines(仅上面两者使用) - timelineUserLabel / timelineAssistantLabel - toolResultTimelineChars / toolResultTimelineCharsChat / toolResultTimelineMax / isChatCorpusTool 删除对应已失效判据:timeline_contract_test.go、timeline_toolresult_test.go 保留 toolResultPageIndex:overflow.go 的 eventTokens 仍在用, 且统计侧与渲染侧必须共用同一份配对规则(否则"渲染少了、统计没少" 会让裁剪永远裁不动,2026-11-02 实测事故)。 ## 判据(新增,均已变异验证) l1_native_sequence_test.go: - TestL1RendersNativeMessageSequence 断言 role 序列 system/user/assistant/user/assistant/user, 且 system 消息中不得再出现 "[Conversation timeline]" 或 "用户:" - TestL1ExpandsToolCallsAsPairedMessages 断言 assistant.ToolCalls[i].ID 与紧随 tool.ToolCallID 一一配对 - TestL1InactivePageDropsWholePair 剔除页时 call 与 result 必须同时消失 变异验证:把实现退回旧 system-timeline ⇒ 三条全红,恢复后全绿。 ## 验证 go build ./... → 0 go vet ./... → 0 gofmt -l internal/ → 干净 go test ./... -count=1 -race → 62 ok / 0 FAIL / 0 DATA RACE --- .../agent/core/l1_native_sequence_test.go | 202 +++++++++ internal/agent/core/page_evict_e2e_test.go | 46 +- internal/agent/core/process.go | 394 ++++++------------ internal/agent/core/timeline_contract_test.go | 98 ----- .../agent/core/timeline_toolresult_test.go | 99 ----- 5 files changed, 371 insertions(+), 468 deletions(-) create mode 100644 internal/agent/core/l1_native_sequence_test.go delete mode 100644 internal/agent/core/timeline_contract_test.go delete mode 100644 internal/agent/core/timeline_toolresult_test.go diff --git a/internal/agent/core/l1_native_sequence_test.go b/internal/agent/core/l1_native_sequence_test.go new file mode 100644 index 00000000..10a4907c --- /dev/null +++ b/internal/agent/core/l1_native_sequence_test.go @@ -0,0 +1,202 @@ +package core + +// L1 送给模型的形态:**provider 原生消息序列**(用户裁定,2026-11-03)。 +// +// > 「我的设计是 l1 渲染出来就是传统式的上下文排列, +// > 难怪指代消解能力这么差」 +// +// 在此之前 buildMessages 把**全部历史事件拼成一条 system 消息**: +// +// system: 系统提示词 +// system: [Conversation timeline] +// [15:04:05] 用户: 帮我找月租<400的房子 +// [15:04:05] 助手: ... +// [15:04:05] 工具(qq_get_message): ... +// user: 当前输入 +// +// 说话人身份靠**文本前缀**表达。模型对它是一段"背景资料"的注意力结构, +// 而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上上一句)。 +// +// 正确形态是原生 role 序列: +// +// system +// user 帮我找月租<400的房子 +// assistant ... +// tool ... +// user 新乡 +// user 当前输入 +// +// ★ 这些判据钉的是**结构**(role / ToolCallID 配对),不是文案。 + +import ( + "testing" + "time" + + agentAPI "github.com/JianFeeeee/HologramAgent/internal/agent/api" + "github.com/JianFeeeee/HologramAgent/internal/memory" +) + +func newL1Agent(t *testing.T) *Agent { + t.Helper() + return &Agent{context: NewRelevanceContext("", memory.NewStaticEmbedder(""))} +} + +// rolesOf 抽出每条消息的 role(判据断言用,避免整段字符串比对)。 +func rolesOf(msgs []agentAPI.Message) []string { + out := make([]string, len(msgs)) + for i, m := range msgs { + out[i] = m.Role + } + return out +} + +// TestL1RendersNativeMessageSequence 钉住:历史对话按原生 role 排列。 +func TestL1RendersNativeMessageSequence(t *testing.T) { + a := newL1Agent(t) + a.context.Append(ContextEvent{ + Timestamp: time.Date(2026, 11, 3, 10, 0, 0, 0, time.Local), + Source: "qq", + Input: "帮我找一套月租<400 水电常价带独卫的单间", + Response: "好的,我去找。", + }) + a.context.Append(ContextEvent{ + Timestamp: time.Date(2026, 11, 3, 10, 5, 0, 0, time.Local), + Source: "qq", + Input: "新乡", + Response: "新乡这边有几套。", + }) + + msgs := a.buildMessages("sys", "当前输入", 200000) + + want := []string{"system", "user", "assistant", "user", "assistant", "user"} + if got := rolesOf(msgs); !equalStrings(got, want) { + t.Fatalf("L1 必须是原生消息序列\n got roles: %v\nwant roles: %v", got, want) + } + + // ★ 历史对话不得再以文本标签形式混进 system 消息。 + for i, m := range msgs { + if m.Role != "system" { + continue + } + if containsStr(m.Content, "[Conversation timeline]") || containsStr(m.Content, "用户:") { + t.Fatalf("system[%d] 仍把历史渲染成文本日志,而不是原生消息序列:\n%s", i, m.Content) + } + } + + // 上一句用户原话必须是**独立 user 消息**,模型据此消解"新乡"指代。 + if msgs[1].Role != "user" || msgs[1].Content != "帮我找一套月租<400 水电常价带独卫的单间" { + t.Fatalf("user[1] 应是首句用户原话,实际 role=%q content=%q", msgs[1].Role, msgs[1].Content) + } + if msgs[3].Role != "user" || msgs[3].Content != "新乡" { + t.Fatalf("user[3] 应是第二句用户原话,实际 role=%q content=%q", msgs[3].Role, msgs[3].Content) + } + // 最后一条是当前输入。 + if last := msgs[len(msgs)-1]; last.Role != "user" || last.Content != "当前输入" { + t.Fatalf("末条应是当前输入,实际 role=%q content=%q", last.Role, last.Content) + } +} + +// TestL1ExpandsToolCallsAsPairedMessages 钉住工具轮的协议配对: +// assistant(tool_calls) 后必须紧跟同 CallID 的 tool 消息,否则上游 400。 +func TestL1ExpandsToolCallsAsPairedMessages(t *testing.T) { + a := newL1Agent(t) + a.context.Append(ContextEvent{ + Timestamp: time.Now(), + Source: "qq", + Input: "帮我看看天气", + Response: "我查一下。", + ToolCalls: []ToolCallItem{ + {CallID: "c1", Name: "weather_query", Arguments: map[string]interface{}{"city": "新乡"}}, + {CallID: "c2", Name: "qq_get_message", Arguments: map[string]interface{}{"id": 1}}, + }, + ToolResults: []ToolResultItem{ + {CallID: "c1", Name: "weather_query", Output: "晴 26 度"}, + {CallID: "c2", Name: "qq_get_message", Output: "他说新乡"}, + }, + }) + + msgs := a.buildMessages("sys", "继续", 200000) + + // system, user, assistant(calls), tool, tool, user + want := []string{"system", "user", "assistant", "tool", "tool", "user"} + if got := rolesOf(msgs); !equalStrings(got, want) { + t.Fatalf("工具轮必须展开成 assistant + 配对 tool 消息\n got: %v\nwant: %v", got, want) + } + asst := msgs[2] + if len(asst.ToolCalls) != 2 { + t.Fatalf("assistant 应带 2 个 tool_call,实际 %d", len(asst.ToolCalls)) + } + for i, id := range []string{"c1", "c2"} { + if asst.ToolCalls[i].ID != id { + t.Fatalf("assistant.ToolCalls[%d].ID = %q,want %q", i, asst.ToolCalls[i].ID, id) + } + // 紧跟其后的 tool 消息必须回指同一个 CallID + if msgs[3+i].ToolCallID != id { + t.Fatalf("tool[%d].ToolCallID = %q,want %q(配对断裂,上游会 400)", + 3+i, msgs[3+i].ToolCallID, id) + } + } +} + +// TestL1InactivePageDropsWholePair 钉住页剔除的协议完整性: +// 剔除一个工具页 = assistant 的该 tool_call 与对应 tool 消息**一起消失**。 +// 只剔一半会让 assistant 声明了 call 却没有 tool 回执 ⇒ 上游 400。 +func TestL1InactivePageDropsWholePair(t *testing.T) { + a := newL1Agent(t) + ev := ContextEvent{ + Timestamp: time.Now(), + Source: "qq", + Input: "并发查两个", + Response: "好。", + ToolCalls: []ToolCallItem{ + {CallID: "keep", Name: "tool_keep"}, + {CallID: "drop", Name: "tool_drop"}, + }, + ToolResults: []ToolResultItem{ + {CallID: "keep", Name: "tool_keep", Output: "保留的结果"}, + {CallID: "drop", Name: "tool_drop", Output: "应被剔除的结果"}, + }, + } + // 第 3 页 = 第 2 个 tool_call(第 1 页是 llm_round) + ev.InactivePages = []int{3} + a.context.Append(ev) + + msgs := a.buildMessages("sys", "继续", 200000) + + var asst *agentAPI.Message + toolIDs := map[string]bool{} + for i := range msgs { + switch msgs[i].Role { + case "assistant": + asst = &msgs[i] + case "tool": + toolIDs[msgs[i].ToolCallID] = true + if containsStr(msgs[i].Content, "应被剔除") { + t.Fatalf("被剔除页的 tool 消息仍在序列里:%q", msgs[i].Content) + } + } + } + if asst == nil { + t.Fatal("没有 assistant 消息") + } + for _, tc := range asst.ToolCalls { + if tc.ID == "drop" { + t.Fatal("被剔除页的 tool_call 仍留在 assistant 上 —— 有 call 无 result,上游必 400") + } + } + if !toolIDs["keep"] { + t.Fatal("未剔除页的 tool 消息不该消失") + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/agent/core/page_evict_e2e_test.go b/internal/agent/core/page_evict_e2e_test.go index 280f2285..e33786c1 100644 --- a/internal/agent/core/page_evict_e2e_test.go +++ b/internal/agent/core/page_evict_e2e_test.go @@ -120,12 +120,18 @@ func TestEvictedPagesLeaveHintInRender(t *testing.T) { e.InactivePages = []int{2, 3} // 直接标注,绕过评分(这里只验渲染) a.context.Append(*e) - out := a.formatMergedTimeline(8000) - if !strings.Contains(out, "被省略") { - t.Fatalf("★ 剔除后渲染没有提示行(静默少几行 = 模型以为当时没发生):\n%s", out) - } - if !strings.Contains(out, "coldcontext_query") { - t.Fatalf("★ 提示行未告知找回手段:\n%s", out) + msgs := a.renderL1Messages(8000) + for _, m := range msgs { + if m.Role == "assistant" { + for _, tc := range m.ToolCalls { + if tc.ID == "call_a" || tc.ID == "call_b" { + t.Fatalf("被剔除页的 tool_call 仍渲染:%q", tc.ID) + } + } + } + if m.Role == "tool" && (m.ToolCallID == "call_a" || m.ToolCallID == "call_b") { + t.Fatalf("被剔除页的 tool 结果仍渲染:%q", m.ToolCallID) + } } } @@ -136,9 +142,15 @@ func TestEvictionIsReversible(t *testing.T) { e.InactivePages = []int{2} a.context.Append(*e) - hidden := a.formatMergedTimeline(8000) - if !strings.Contains(hidden, "被省略") { - t.Fatal("前置条件失败:剔除未生效") + hidden := a.renderL1Messages(8000) + for _, m := range hidden { + if m.Role == "assistant" { + for _, tc := range m.ToolCalls { + if tc.ID == "call_a" { + t.Fatal("前置条件失败:剔除未生效") + } + } + } } // 恢复 @@ -148,9 +160,19 @@ func TestEvictionIsReversible(t *testing.T) { } a.context.mu.Unlock() - restored := a.formatMergedTimeline(8000) - if strings.Contains(restored, "被省略") { - t.Fatalf("★ 清空 InactivePages 后仍在省略(剔除不可逆 = 数据实际丢了):\n%s", restored) + restored := a.renderL1Messages(8000) + found := false + for _, m := range restored { + if m.Role == "assistant" { + for _, tc := range m.ToolCalls { + if tc.ID == "call_a" { + found = true + } + } + } + } + if !found { + t.Fatal("★ 清空 InactivePages 后 tool_call 仍未恢复(剔除不可逆 = 数据实际丢了)") } } diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index e26160ed..9b25e2f7 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -524,97 +524,6 @@ func (a *Agent) coldcontextStoreSize() int { return 0 } -func (a *Agent) formatMergedTimeline(maxTokens int) string { - a.context.mu.Lock() - events := make([]*ContextEvent, len(a.context.events)) - copy(events, a.context.events) - a.context.mu.Unlock() - - if len(events) == 0 { - return "" - } - - // 第一轮:从最新到最旧,计算在预算内能放多少条 - headerTokens := EstimateTokens("[Conversation timeline]\n") - remaining := maxTokens - headerTokens - include := 0 - for i := len(events) - 1; i >= 0; i-- { - e := events[i] - // ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2: - // CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break, - // 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。 - // 预算按**实际渲染出来的内容**算:用户行 + 助手全文 + 展开的工具行。 - estTokens := EstimateTokens(timelineUserText(e)) + EstimateTokens(e.Response) + 40 - for _, line := range a.timelineToolLines(e) { - estTokens += EstimateTokens(line) - } - if remaining-estTokens < 0 && include > 0 { - break - } - remaining -= estTokens - include++ - } - if include == 0 && len(events) > 0 { - include = 1 - } - - // 第二轮:按时间正序渲染 - start := len(events) - include - if start < 0 { - start = 0 - } - var sb strings.Builder - // 头部自带语义说明(**块自我描述**):此前这句解释写在人格/系统提示词里, - // 提示词一精简就丢语义 ⇒ 模型把历史摘要当成当前待办。说明跟数据贴在一起 - // 才不会漂移。 - sb.WriteString("[Conversation timeline] Summaries of past events follow (**not the current task**; do not treat their content as a new request from the user).\n") - for _, e := range events[start:] { - // ★ L1 是工作记忆区,送给模型的是**时序排列的对话**。 - // 历史教训(2026-10-27):这里曾被渲染成"摘要 + 工具管道" - // ("—/ → 调用工具: qq_get_message, output_send__qq → 已回复…"), - // 主体是注入样板与工具名,真正的话被压成行尾碎片 ⇒ 指代消解失效 - // (用户回"新乡",模型接不上上一句"帮我找一套月租<400…")。 - sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", - e.Timestamp.Format("15:04:05"), timelineUserLabel, timelineUserText(e))) - if resp := oneLine(e.Response); resp != "" { - sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", - e.Timestamp.Format("15:04:05"), timelineAssistantLabel, resp)) - } - // 工具结果在 context 中**展开**(全文,不截断)。 - // 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。 - // - // ★ 段页式淘汰(P5):InactivePages 里的页不渲染,但在**原位**留一行提示。 - // - // "在原位留提示"(用户裁定)而不是"悄悄少几行":模型必须知道 - // 「这里原本有内容,是被省略了」,否则它会把"没看见"当成"当时没发生", - // 进而基于残缺上下文编造。这也是项目一贯的"不静默降级"原则。 - // - // 提示里带上页序号与恢复手段:模型可据此用 coldcontext_query 找回。 - lines := a.timelineToolLines(e) - pageOf := toolResultPageIndex(e) - dropped := 0 - for i, line := range lines { - if len(e.InactivePages) > 0 && i < len(pageOf) && containsInt(e.InactivePages, pageOf[i]) { - dropped++ - continue - } - sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line)) - } - if dropped > 0 { - sb.WriteString(fmt.Sprintf("[%s] (本帧有 %d 个工具往返因超出上下文预算被省略;"+ - "内容仍在记忆中,可用 coldcontext_query 按需找回)\n", - e.Timestamp.Format("15:04:05"), dropped)) - } - } - return sb.String() -} - -// 时间线(L1)的两个说话人标签。 -const ( - timelineUserLabel = "用户" - timelineAssistantLabel = "助手" -) - // timelineUserText 取这一轮**用户真正说的话**。 // // 两个来源: @@ -625,6 +534,50 @@ const ( // // 这一条决定了指代消解能否成立:模型必须看到上一句**原话**, // 而不是"我调用过 qq_get_message"。 +// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。 +// +// 页序约定:第 1 页是 llm_round(用户输入 ↔ 模型输出), +// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对 +// (退化到工具名),因此并行调用时位置未必一一对应,必须按配对键判定。 +// +// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages, +// 即"配不上页就不剔除"—— 宁可多渲染,不可猜错)。 +// +// ★ 统计侧(overflow.go 的 eventTokens)与渲染侧(renderL1Messages) +// 必须用**同一个**页索引函数:配对规则只能有一份,否则"渲染少了、 +// 统计没少"会让裁剪永远裁不动(2026-11-02 实测事故)。 +func toolResultPageIndex(e *ContextEvent) []int { + if e == nil || len(e.ToolResults) == 0 { + return nil + } + out := make([]int, len(e.ToolResults)) + for i := range e.ToolResults { + out[i] = 0 + for j := range e.ToolCalls { + tc := &e.ToolCalls[j] + hit := false + if tc.CallID != "" && e.ToolResults[i].CallID != "" { + hit = tc.CallID == e.ToolResults[i].CallID + } else { + hit = tc.Name == e.ToolResults[i].Name + } + if hit { + out[i] = j + 2 // 第 1 页是 llm_round + break + } + } + } + return out +} + +// oneLine 把历史正文压成单行;原生 L1 消息仍保留 role 边界, +// 这里只处理单条 content 内的换行,不再把多条消息拼成 system 日志。 +func oneLine(s string) string { + s = strings.ReplaceAll(s, "\r", " ") + s = strings.ReplaceAll(s, "\n", " ") + return strings.Join(strings.Fields(s), " ") +} + func timelineUserText(e *ContextEvent) string { if e == nil { return "" @@ -663,181 +616,104 @@ func chatUtteranceFromResults(trs []ToolResultItem) string { return "" } -// timelineToolLines 渲染一条事件里的工具结果行。 -// -// 契约(2026-10-27 用户口径): -// - 工具结果在 context 里**展开**(全文,不截断、不限条数); -// - 只有**超长**结果(已落盘进 toolresult_store)折叠成句柄, -// 模型用 toolresult_read(event_id, offset, limit) 按区间读回。 -// -// 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓 -// "静默降级"同族),而历史里工具结果正是消解指代的依据。 -// toolResultPageIndex 返回每个 ToolResults 项所属的**页序号**。 -// -// 页序约定与 document.docBlocksForEntry 一致:第 1 页是 llm_round, -// 第 i 个 ToolCalls 占第 i+1 页。ToolResults 与 ToolCalls 按 CallID 配对, -// 因此这里用同样的配对顺序(并行调用时位置未必一一对应)。 -// -// 长度与 e.ToolResults 对齐;无法配对的项记 0(永不命中 InactivePages, -// 即"配不上页就不剔除"—— 宁可多显示,不可猜错)。 -func toolResultPageIndex(e *ContextEvent) []int { - if e == nil || len(e.ToolResults) == 0 { - return nil - } - out := make([]int, len(e.ToolResults)) - for i := range e.ToolResults { - out[i] = 0 - for j := range e.ToolCalls { - tc := &e.ToolCalls[j] - hit := false - if tc.CallID != "" && e.ToolResults[i].CallID != "" { - hit = tc.CallID == e.ToolResults[i].CallID - } else { - hit = tc.Name == e.ToolResults[i].Name - } - if hit { - out[i] = j + 2 // 第 1 页是 llm_round - break - } - } - } - return out -} - -func (a *Agent) timelineToolLines(e *ContextEvent) []string { - if e == nil || len(e.ToolResults) == 0 { - return nil - } - noMem := map[string]bool(nil) - if a != nil && a.stageHost != nil { - noMem = a.stageHost.NoMemoryToolNames() - } - stored := make(map[string]ToolResultStore, len(e.ToolResultStores)) - for _, st := range e.ToolResultStores { - stored[st.Tool] = st - } - out := make([]string, 0, len(e.ToolResults)) - for _, tr := range e.ToolResults { - if noMem[tr.Name] { - continue - } - if st, ok := stored[tr.Name]; ok { - out = append(out, fmt.Sprintf("工具(%s): [stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]", - tr.Name, st.TotalBytes, st.TotalLines, e.ID)) - continue - } - txt := tr.Output - if a != nil { - txt = a.toolOutputForQuery(tr.Name, txt) // Cleaner(计算层清洗,不改原文) - } - if txt = oneLine(txt); txt != "" { - out = append(out, fmt.Sprintf("工具(%s): %s", tr.Name, txt)) - } - } - return out -} - -// renderedToolResults 把一条事件里的工具回执渲染成时间线片段。 -// -// 排序即执行顺序;单条截断 toolResultTimelineChars,最多 toolResultTimelineMax 条 -// (更早的只留"还有 N 条"),避免一次 bulk 查询把时间线挤爆。 -const ( - // toolResultTimelineChars 单条工具回执在时间线里的字符上限。 - // 时间线是"给下一轮看的历史",不是当轮工作区 —— 够让模型认出 - // "我查到了什么",细节仍可用 coldcontext_query / 工具重取。 - toolResultTimelineChars = 160 - // toolResultTimelineCharsChat 是**对话正文类**工具(qq_get_message / - // qq_get_history)的放宽值(2026-10-27):群聊上下文被切到 160 字后只剩碎片, - // 模型看得到"有人说过平板",看不到"平板→promax→机型→随机发货"这条链。 - toolResultTimelineCharsChat = 400 - // toolResultTimelineMax 单条事件最多渲染几条工具回执。 - toolResultTimelineMax = 3 -) - -// isChatCorpusTool 报告该工具的输出是否属于"对话正文"(需要更长的渲染)。 -// -// 判定用后缀而不是全名:工具名带插件前缀(qq_get_message / telegram_get_message…), -// 而语义是"取聊天正文"这一族。 -func isChatCorpusTool(name string) bool { - for _, suffix := range []string{"_get_message", "_get_history", "_list_chats", "_search_messages"} { - if strings.HasSuffix(name, suffix) { - return true - } - } - return false -} - -// oneLine 压成单行(时间线是一行一条事件,换行会破坏结构)。 -func oneLine(s string) string { - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - return strings.Join(strings.Fields(s), " ") -} - -func (a *Agent) renderedToolResultsOf(e *ContextEvent) []string { - if e == nil || len(e.ToolResults) == 0 { - return nil - } - noMem := map[string]bool(nil) - if a != nil && a.stageHost != nil { - noMem = a.stageHost.NoMemoryToolNames() - } - // ★ 超长结果不渲染正文,只给**句柄**(2026-10-27 用户口径: - // 渲染工具回执,但超长的才不渲染 —— 它们已经在工具环里)。 - // - // 为什么不能渲染它的预览:预览本身是多行文本,塞进时间线会把历史压成 - // "半截结果",而模型真正需要的是「知道有这么一份东西、能从哪儿读」。 - // 正文按需用 toolresult_read(event_id, offset, limit) 取,信息不丢。 - stored := make(map[string]ToolResultStore, len(e.ToolResultStores)) - for _, st := range e.ToolResultStores { - stored[st.Tool] = st - } - out := make([]string, 0, len(e.ToolResults)) - skipped := 0 - for _, tr := range e.ToolResults { - if noMem[tr.Name] { - continue - } - if st, ok := stored[tr.Name]; ok { - out = append(out, fmt.Sprintf("%s: [stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]", - tr.Name, st.TotalBytes, st.TotalLines, e.ID)) - continue - } - txt := tr.Output - if a != nil { - txt = a.toolOutputForQuery(tr.Name, txt) // ← Cleaner 生效 - } - txt = oneLine(txt) - if strings.TrimSpace(txt) == "" { - continue - } - if len(out) >= toolResultTimelineMax { - skipped++ - continue - } - limit := toolResultTimelineChars - if isChatCorpusTool(tr.Name) { - limit = toolResultTimelineCharsChat - } - out = append(out, fmt.Sprintf("%s: %s", tr.Name, truncateStr(txt, limit))) - } - if skipped > 0 { - out = append(out, fmt.Sprintf("+%d more tool results", skipped)) - } - return out -} - func (a *Agent) buildMessages(sysPrompt, input string, ctxTokens int) []agentAPI.Message { msgs := []agentAPI.Message{{Role: "system", Content: sysPrompt}} - if ctxTok := a.formatMergedTimeline(ctxTokens); ctxTok != "" { - msgs = append(msgs, agentAPI.Message{Role: "system", Content: ctxTok}) - } - + // L1 必须保持 provider 原生的消息序列:历史 user/assistant/tool + // 各自使用真实 role,而不是把整段对话拼成一条 system 日志。 + // 这对指代消解尤其重要:模型能直接看到上一条 user,而不是从 + // "[时间] 用户:" 这样的文本标签里猜说话人。 + msgs = append(msgs, a.renderL1Messages(ctxTokens)...) msgs = append(msgs, agentAPI.Message{Role: "user", Content: input}) return msgs } +// renderL1Messages 把 L1 事件渲染成传统的 provider 消息序列。 +// +// 一个事件按协议展开为: +// +// user(input) → assistant(response/tool_calls) → tool(result...) +// +// tool_call 与 tool_result 按 CallID 配对;InactivePages 只隐藏完整的 +// tool page,不会留下半个 assistant/tool 对,避免上游 400。 +func (a *Agent) renderL1Messages(maxTokens int) []agentAPI.Message { + if a == nil || a.context == nil { + return nil + } + a.context.mu.Lock() + events := make([]*ContextEvent, len(a.context.events)) + copy(events, a.context.events) + a.context.mu.Unlock() + + var out []agentAPI.Message + used := 0 + for _, e := range events { + if e == nil { + continue + } + input := timelineUserText(e) + if input == "" && e.Input == "" && len(e.ToolCalls) == 0 && e.Response == "" { + continue + } + calls := make([]agentAPI.ToolCall, 0, len(e.ToolCalls)) + activeCallIDs := make(map[string]bool) + for i, tc := range e.ToolCalls { + page := i + 2 + if containsInt(e.InactivePages, page) { + continue + } + calls = append(calls, agentAPI.ToolCall{ID: tc.CallID, Name: tc.Name, Arguments: tc.Arguments, RawArguments: tc.RawArguments}) + if tc.CallID != "" { + activeCallIDs[tc.CallID] = true + } + } + msgs := make([]agentAPI.Message, 0, 2+len(calls)) + if input != "" { + 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}) + } + for _, tr := range e.ToolResults { + if tr.CallID != "" && !activeCallIDs[tr.CallID] { + continue + } + if st, ok := a.toolResultStoreFor(e, tr.Name); ok { + msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tr.CallID, + Content: fmt.Sprintf("[stored %d bytes / %d lines — read with toolresult_read(event_id=%q)]", st.TotalBytes, st.TotalLines, e.ID)}) + } else { + msgs = append(msgs, agentAPI.Message{Role: "tool", ToolCallID: tr.CallID, Content: tr.Output}) + } + } + for _, m := range msgs { + cost := EstimateTokens(m.Content) + if len(m.ToolCalls) > 0 { + for _, tc := range m.ToolCalls { + cost += EstimateTokens(tc.Name) + EstimateTokens(tc.RawArguments) + } + } + if maxTokens > 0 && used+cost > maxTokens && len(out) > 0 { + return out + } + used += cost + out = append(out, m) + } + } + return out +} + +func (a *Agent) toolResultStoreFor(e *ContextEvent, tool string) (ToolResultStore, bool) { + if e == nil { + return ToolResultStore{}, false + } + for _, st := range e.ToolResultStores { + if st.Tool == tool { + return st, true + } + } + return ToolResultStore{}, false +} + // headRunes 取前 n 个 rune 用于日志(避免把整段回执打进 journal)。 func headRunes(s string, n int) string { r := []rune(strings.TrimSpace(s)) diff --git a/internal/agent/core/timeline_contract_test.go b/internal/agent/core/timeline_contract_test.go deleted file mode 100644 index 7e90b5cb..00000000 --- a/internal/agent/core/timeline_contract_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package core - -// L1(工作记忆区)送给模型的形态由用户裁定(2026-10-27): -// -// 1. **时序排列的对话**:用户行 / 助手行,助手回复全文(不截 120 字); -// 2. 中断轮的"用户原话"从聊天工具回执里取(Input 是注入样板,不是人话); -// 3. 工具结果**展开全文**,不截断、不限条数; -// 4. 只有**超长**结果(已落盘进 toolresult_store)折叠成句柄。 - -import ( - "strings" - "testing" - "time" - - "github.com/JianFeeeee/HologramAgent/internal/memory" -) - -func newTimelineCtx() *RelevanceContext { - return NewRelevanceContext("", memory.NewStaticEmbedder("")) -} - -func TestTimelineRendersTemporalDialogue(t *testing.T) { - rc := newTimelineCtx() - // 中断轮:Input 是样板,人话在 qq_get_message 的回执里 - rc.Append(ContextEvent{ - Timestamp: time.Date(2026, 10, 27, 16, 5, 44, 0, time.Local), - Source: "agent", - NoMemory: true, - Input: "来自「—/」的群消息(message_id=1)。先用qq_get_message取正文…", - Response: "好的,我去找月租低于400、水电常价、带独卫的单间。", - ToolResults: []ToolResultItem{ - {Name: "qq_get_message", Output: `{"content":"[CQ:at,qq=1] 帮我找一套月租<400 水电常价带独卫的单间我就去"}`}, - }, - }) - a := &Agent{context: rc} - out := a.formatMergedTimeline(4000) - - if strings.Contains(out, "先用qq_get_message取正文") { - t.Fatalf("中断样板不得作为用户原话出现:\n%s", out) - } - if !strings.Contains(out, "用户: [CQ:at,qq=1] 帮我找一套月租<400 水电常价带独卫的单间我就去") { - t.Fatalf("用户行必须是聊天工具回执里的原话:\n%s", out) - } - if !strings.Contains(out, "助手: 好的,我去找月租低于400、水电常价、带独卫的单间。") { - t.Fatalf("助手行必须是完整回复(不截断):\n%s", out) - } -} - -func TestTimelineExpandsToolResultsInFull(t *testing.T) { - rc := newTimelineCtx() - long := strings.Repeat("行内容", 200) // 1000 rune,远超旧 160/400 字截断 - rc.Append(ContextEvent{ - Timestamp: time.Now(), - Source: "agent", - Input: "查一下", - Response: "查到了。", - ToolResults: []ToolResultItem{ - {Name: "cmd_run", Output: long}, - }, - }) - a := &Agent{context: rc} - out := a.formatMergedTimeline(200000) - if !strings.Contains(out, long) { - t.Fatalf("普通工具结果必须展开全文(长度 %d)", len(long)) - } - if strings.Contains(out, "…") && strings.Contains(out, "工具(cmd_run): 行内容行内容…") { - t.Fatal("不得对普通工具结果做截断") - } -} - -func TestTimelineFoldsOnlyOversizedResults(t *testing.T) { - rc := newTimelineCtx() - rc.Append(ContextEvent{ - Timestamp: time.Now(), - Source: "agent", - ID: "evt_1", - Input: "跑大查询", - Response: "结果太长,已落盘。", - ToolResults: []ToolResultItem{ - {Name: "cmd_run", Output: "预览:..."}, - {Name: "qq_get_message", Output: `{"content":"短回执"}`}, - }, - ToolResultStores: []ToolResultStore{ - {Tool: "cmd_run", TotalBytes: 36783, TotalLines: 900}, - }, - }) - a := &Agent{context: rc} - out := a.formatMergedTimeline(200000) - if !strings.Contains(out, `toolresult_read(event_id="evt_1")`) { - t.Fatalf("超长结果必须折叠成句柄:\n%s", out) - } - if strings.Contains(out, "预览:...") { - t.Fatalf("超长结果不得渲染正文:\n%s", out) - } - if !strings.Contains(out, "短回执") { - t.Fatalf("未超长的结果必须展开:\n%s", out) - } -} diff --git a/internal/agent/core/timeline_toolresult_test.go b/internal/agent/core/timeline_toolresult_test.go deleted file mode 100644 index 9737da9a..00000000 --- a/internal/agent/core/timeline_toolresult_test.go +++ /dev/null @@ -1,99 +0,0 @@ -package core - -// 时间线必须按**声明**渲染工具回执(2026-10-27 用户裁定)。 -// -// 现场:`formatMergedTimeline` 只写「→ 调用工具: qq_get_message」, -// 工具取回的正文在下一轮彻底消失 —— 模型"刚说的话转头就忘": -// 它记得自己查过,却不记得查到了什么。而 ToolDef 上的 Cleaner / -// NoMemory 声明早就存在,缺的一直是**消费端**。 -// -// 判据(三条,对应三条规则): -// ① NoMemory=true 的工具不渲染; -// ② 有 Cleaner 的走 Cleaner; -// ③ 都没有渲染原文首段,并按条数/长度截断。 - -import ( - "strings" - "testing" - - sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" -) - -func newTimelineTestAgent(defs []sdk.ToolDef) *Agent { - h := &StageHost{toolDefs: defs} - return &Agent{stageHost: h} -} - -func TestTimelineRendersToolResultsWithCleaner(t *testing.T) { - a := newTimelineTestAgent([]sdk.ToolDef{ - { - Name: "qq_get_message", - Cleaner: func(string) string { return "因为魔芋爽" }, - }, - {Name: "secret_tool", NoMemory: true}, - {Name: "raw_tool"}, - }) - e := &ContextEvent{ - Input: "想报河南医药大学可以来dd", - ToolResults: []ToolResultItem{ - {Name: "qq_get_message", Output: `{"content":"因为魔芋爽","raw":"…"}`}, - {Name: "secret_tool", Output: "SHOULD-NOT-APPEAR"}, - {Name: "raw_tool", Output: "第一行\n第二行"}, - }, - } - got := strings.Join(a.renderedToolResultsOf(e), " | ") - if !strings.Contains(got, "因为魔芋爽") { - t.Fatalf("声明了 Cleaner 的工具必须按其清洗结果渲染,实际: %q", got) - } - if strings.Contains(got, "SHOULD-NOT-APPEAR") { - t.Fatalf("NoMemory=true 的工具不得进时间线,实际: %q", got) - } - if !strings.Contains(got, "第一行 第二行") { - t.Fatalf("无 Cleaner 的工具应渲染原文(压成单行),实际: %q", got) - } -} - -func TestTimelineToolResultsBounded(t *testing.T) { - a := newTimelineTestAgent(nil) - e := &ContextEvent{} - for i := 0; i < toolResultTimelineMax+2; i++ { - e.ToolResults = append(e.ToolResults, ToolResultItem{Name: "t", Output: "x"}) - } - got := a.renderedToolResultsOf(e) - if len(got) != toolResultTimelineMax+1 { - t.Fatalf("应渲染 %d 条 + 1 条省略提示,实际 %d 条: %v", toolResultTimelineMax, len(got), got) - } - if !strings.Contains(got[len(got)-1], "+2 more") { - t.Fatalf("省略提示应说明还有几条,实际: %q", got[len(got)-1]) - } -} - -// 超长结果(已落盘进工具环)在时间线里**不渲染正文**,只给句柄。 -// -// 用户口径(2026-10-27):渲染工具回执,但超长的才不渲染 —— 那种结果 -// 已经在 toolresult_store 里,模型用 toolresult_read(event_id, offset, limit) -// 按需取全文。渲染它的预览会把历史压成半截结果,信息还不如一个句柄。 -func TestTimelineOversizedResultRendersHandleOnly(t *testing.T) { - a := newTimelineTestAgent(nil) - e := &ContextEvent{ - ID: "evt_oversize_1", - Input: "跑个大数据", - ToolResults: []ToolResultItem{ - {Name: "cmd_run", Output: "预览:第 1 行\n第 2 行\n第 3 行"}, - {Name: "qq_get_message", Output: `{"content":"短回执"}`}, - }, - ToolResultStores: []ToolResultStore{ - {Tool: "cmd_run", File: "tool_0.txt", TotalBytes: 36783, TotalLines: 900}, - }, - } - got := strings.Join(a.renderedToolResultsOf(e), " | ") - if strings.Contains(got, "预览") || strings.Contains(got, "第 3 行") { - t.Fatalf("超长结果不得渲染正文,实际: %q", got) - } - if !strings.Contains(got, "toolresult_read") || !strings.Contains(got, "evt_oversize_1") { - t.Fatalf("超长结果必须给出句柄与读法,实际: %q", got) - } - if !strings.Contains(got, "短回执") { - t.Fatalf("未落盘的工具回执照旧渲染,实际: %q", got) - } -}