fix(memory): 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:   当前输入

说话人身份靠**文本前缀**表达。模型对它拿到的是"背景资料"式的注意力
结构,而不是"我说过的对话"——指代消解因此失效(用户回"新乡"接不上
上一句"帮我找月租<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
This commit is contained in:
JianFeeeee
2026-11-03 14:37:53 +08:00
parent 81a0f5f598
commit 24b03e56e9
5 changed files with 371 additions and 468 deletions

View File

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

View File

@ -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 仍未恢复(剔除不可逆 = 数据实际丢了)")
}
}

View File

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

View File

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

View File

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