fix(memory): L1 恢复为"工作记忆区 = 时序对话",工具结果展开、仅超长折叠

用户裁定的 L1 契约(2026-10-27):
  · 保留 m 条(core.agent.max_context_size)、保护最新 n 条
    (core.agent.context.protected_count)、其余按相似度淘汰 → 我的上一版把
    m 写到了不存在的键(core.agent.context.max_context_size),运行时仍读
    core.agent.max_context_size=200 ⇒ L1 堆到 119 条,工作记忆退化成历史堆。
  · 送给模型的是**时序排列的对话** → 曾被渲染成"摘要 + 工具管道"
    ("—/ → 调用工具: a, b → 已回复…",助手回复还截 120 字),
    指代消解因此失效(用户回"新乡",接不上"帮我找一套月租<400…")。
  · 工具结果在 context 中**展开全文**,只有**超长**结果折叠成句柄
    (toolresult_store + toolresult_read 按区间读回)→ 曾额外加 160/400 字、
    每事件 3 条的截断,等于把已展开的结果又藏回去。

本次改动(仅渲染层,不动淘汰/归档/中断语义):
  · formatMergedTimeline 渲染 [ts] 用户: … / [ts] 助手: …(回复全文)
  · 中断轮的"用户原话"取自 qq_get_message/qq_get_history 的回执正文
    (Input 是注入样板,不是人话)
  · timelineToolLines:普通结果全文展开;ToolResultStores 命中的只给句柄
  · 预算估算按实际渲染内容计(用户行 + 助手全文 + 工具行)

判据:internal/agent/core/timeline_contract_test.go
(时序对话/原话来源/全文展开/仅超长折叠)
This commit is contained in:
JianFeeeee
2026-10-27 16:24:09 +08:00
parent 279b61198c
commit 0f98f36e71
2 changed files with 212 additions and 29 deletions

View File

@ -0,0 +1,98 @@
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)
}
}