mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 14:57:38 +00:00
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:
@ -543,14 +543,10 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
||||
// ❗单位必须与 EstimateTokens 一致(rune×2)。这里曾用 `len()`(**字节**)再 ×2:
|
||||
// CJK 一字 3 字节 ⇒ 中文事件被高估 3 倍,窗口还有余量也会提前 break,
|
||||
// 把更早的事件整段丢掉(实测:2384 字的中文事件被估成 14398 token > 8192)。
|
||||
estTokens := EstimateTokens(e.Source) + EstimateTokens(e.Input) + 40
|
||||
if e.Response != "" {
|
||||
estTokens += 120
|
||||
}
|
||||
// 工具回执现在**会**进时间线(见下方渲染),预算必须如实计入,
|
||||
// 否则"算出来的窗口装得下"与"实际发出去的更长"两笔账对不上。
|
||||
for _, tr := range a.renderedToolResultsOf(e) {
|
||||
estTokens += EstimateTokens(tr)
|
||||
// 预算按**实际渲染出来的内容**算:用户行 + 助手全文 + 展开的工具行。
|
||||
estTokens := EstimateTokens(timelineUserText(e)) + EstimateTokens(e.Response) + 40
|
||||
for _, line := range a.timelineToolLines(e) {
|
||||
estTokens += EstimateTokens(line)
|
||||
}
|
||||
if remaining-estTokens < 0 && include > 0 {
|
||||
break
|
||||
@ -573,33 +569,122 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string {
|
||||
// 才不会漂移。
|
||||
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:] {
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s: %s",
|
||||
e.Timestamp.Format("15:04:05"), e.Source, e.Input))
|
||||
if len(e.ToolsUsed) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" → 调用工具: %s", strings.Join(e.ToolsUsed, ", ")))
|
||||
// ★ 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))
|
||||
}
|
||||
if e.Response != "" {
|
||||
sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120)))
|
||||
// 工具结果在 context 中**展开**(全文,不截断)。
|
||||
// 只有**超长**结果按工具环设计折叠成句柄,模型用 toolresult_read 按区间读回。
|
||||
for _, line := range a.timelineToolLines(e) {
|
||||
sb.WriteString(fmt.Sprintf("[%s] %s\n", e.Timestamp.Format("15:04:05"), line))
|
||||
}
|
||||
// ★ 工具回执按**声明**渲染(2026-10-27 用户裁定:声明了 Cleaner 的工具
|
||||
// 必须被正确渲染进记忆块)。
|
||||
//
|
||||
// 此前这里只写"调用了工具: a, b",工具取回的正文在下一轮彻底消失 ——
|
||||
// 模型因此"刚说的话转头就忘":它记得自己查过,却不记得查到了什么。
|
||||
// 声明契约(ToolDef.NoMemory / Cleaner)早就有,缺的是这个消费端。
|
||||
//
|
||||
// 三条规则:
|
||||
// ① NoMemory=true 的工具**不渲染**(它明确声明"不进记忆",时间线即记忆视图)
|
||||
// ② 有 Cleaner 的走 Cleaner(工具自己说清"哪一段才是语义")
|
||||
// ③ 都没有就渲染原文首段,按预算截断
|
||||
for _, line := range a.renderedToolResultsOf(e) {
|
||||
sb.WriteString(" → " + line)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// 时间线(L1)的两个说话人标签。
|
||||
const (
|
||||
timelineUserLabel = "用户"
|
||||
timelineAssistantLabel = "助手"
|
||||
)
|
||||
|
||||
// timelineUserText 取这一轮**用户真正说的话**。
|
||||
//
|
||||
// 两个来源:
|
||||
// 1. 普通轮 → e.Input;
|
||||
// 2. 中断轮(NoMemory=true,Input 是"来自「X」…先用 qq_get_message 取正文"样板)
|
||||
// → 从该事件的聊天工具回执里取正文(qq_get_message 的 content,
|
||||
// 或 qq_get_history 的 messages 末条)。
|
||||
//
|
||||
// 这一条决定了指代消解能否成立:模型必须看到上一句**原话**,
|
||||
// 而不是"我调用过 qq_get_message"。
|
||||
func timelineUserText(e *ContextEvent) string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if !e.NoMemory {
|
||||
if t := oneLine(e.Input); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
if t := chatUtteranceFromResults(e.ToolResults); t != "" {
|
||||
return t
|
||||
}
|
||||
return oneLine(e.Input)
|
||||
}
|
||||
|
||||
// chatUtteranceFromResults 从聊天类工具回执里取"人说的话"(最近一条)。
|
||||
func chatUtteranceFromResults(trs []ToolResultItem) string {
|
||||
for i := len(trs) - 1; i >= 0; i-- {
|
||||
var one struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(trs[i].Output)), &one) == nil &&
|
||||
strings.TrimSpace(one.Content) != "" {
|
||||
return oneLine(strings.TrimSpace(one.Content))
|
||||
}
|
||||
}
|
||||
for i := len(trs) - 1; i >= 0; i-- {
|
||||
var hist struct {
|
||||
Messages []string `json:"messages"`
|
||||
}
|
||||
if json.Unmarshal([]byte(strings.TrimSpace(trs[i].Output)), &hist) != nil || len(hist.Messages) == 0 {
|
||||
continue
|
||||
}
|
||||
return oneLine(hist.Messages[len(hist.Messages)-1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// timelineToolLines 渲染一条事件里的工具结果行。
|
||||
//
|
||||
// 契约(2026-10-27 用户口径):
|
||||
// - 工具结果在 context 里**展开**(全文,不截断、不限条数);
|
||||
// - 只有**超长**结果(已落盘进 toolresult_store)折叠成句柄,
|
||||
// 模型用 toolresult_read(event_id, offset, limit) 按区间读回。
|
||||
//
|
||||
// 为什么不能截断普通结果:截断会让模型拿到残缺信息且不知情(本仓
|
||||
// "静默降级"同族),而历史里工具结果正是消解指代的依据。
|
||||
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 条
|
||||
|
||||
98
internal/agent/core/timeline_contract_test.go
Normal file
98
internal/agent/core/timeline_contract_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user