From 051247e10f297de7f7a5c471a549a9b197a11142 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Tue, 27 Oct 2026 12:16:06 +0800 Subject: [PATCH] =?UTF-8?q?fix(memory):=20=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E6=8C=89=E5=A3=B0=E6=98=8E=E6=B8=B2=E6=9F=93=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E5=9B=9E=E6=89=A7=20+=20=E8=83=BD=E5=8A=9B=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=85=8D=E7=BD=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 时间线此前只写「→ 调用工具: a, b」,工具取回的正文下一轮彻底消失: 模型记得自己查过,却不记得查到了什么(刚说的话转头就忘)。 ToolDef 上的 Cleaner / NoMemory 声明一直存在,缺的是消费端。 - formatMergedTimeline 渲染 e.ToolResults:Cleaner 优先,NoMemory 工具跳过, 单条 160 字、每事件最多 3 条(+N more),预算估算同步计入 - 模态能力改由配置层声明(ProviderManager.SetCapabilities), 不再靠 provider.(ModalProvider) 断言穿过 adapter 与安全装饰器 - a2a 通道 RecallPolicy none→auto(NoMemory 仍 true:可读不写) --- internal/agent/api/provider.go | 62 ++++++++++++++++ internal/agent/api/provider_caps_test.go | 62 ++++++++++++++++ internal/agent/core/modalfallback.go | 18 ++++- internal/agent/core/process.go | 73 +++++++++++++++++++ .../agent/core/timeline_toolresult_test.go | 69 ++++++++++++++++++ internal/kernelmod/llm/mod.go | 7 ++ 6 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 internal/agent/api/provider_caps_test.go create mode 100644 internal/agent/core/timeline_toolresult_test.go diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 100d423a..e2f50cc0 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -1087,6 +1087,68 @@ type ProviderManager struct { order []string default_ string status map[string]*providerStatus + + // caps 是**配置层声明**的模态能力(源名 → vision/audio)。 + // + // ★ 为什么不靠 `provider.(ModalProvider)` 断言(2026-10-27 用户指正): + // 是否支持视觉是**配置事实**(core.llm.sources..vision=true), + // 而 adapter 只负责格式转换。用类型断言去"猜"能力,等于让能力声明 + // 穿过 adapter 与安全装饰器两层——任何一层漏实现方法,能力就静默变成 + // false(这就是反复出现的"配了视觉却说没有视觉")。 + // 断言只保留为**兜底**(配置未声明时的旧路径)。 + caps map[string]ProviderCaps +} + +// ProviderCaps 是一个 provider 的模态能力(来自配置)。 +type ProviderCaps struct { + Vision bool + Audio bool +} + +// SetCapabilities 由装配层(llm 模块)把配置里的能力声明登记进来。 +func (m *ProviderManager) SetCapabilities(name string, caps ProviderCaps) { + if m == nil || name == "" { + return + } + m.mu.Lock() + if m.caps == nil { + m.caps = map[string]ProviderCaps{} + } + m.caps[name] = caps + m.mu.Unlock() +} + +// Capabilities 返回某 provider 的配置层能力(ok=false 表示配置未声明)。 +func (m *ProviderManager) Capabilities(name string) (ProviderCaps, bool) { + if m == nil { + return ProviderCaps{}, false + } + m.mu.RLock() + defer m.mu.RUnlock() + c, ok := m.caps[name] + return c, ok +} + +// SupportsVision / SupportsAudio 是**面向调用方**的能力查询: +// 先看配置声明,配置没说才退回类型断言(兼容外部/测试 provider)。 +func (m *ProviderManager) SupportsVision(name string) bool { + if c, ok := m.Capabilities(name); ok { + return c.Vision + } + if p := m.Get(name); p != nil { + return ProviderSupportsVision(p) + } + return false +} + +func (m *ProviderManager) SupportsAudio(name string) bool { + if c, ok := m.Capabilities(name); ok { + return c.Audio + } + if p := m.Get(name); p != nil { + return ProviderSupportsAudio(p) + } + return false } const ( diff --git a/internal/agent/api/provider_caps_test.go b/internal/agent/api/provider_caps_test.go new file mode 100644 index 00000000..48add702 --- /dev/null +++ b/internal/agent/api/provider_caps_test.go @@ -0,0 +1,62 @@ +package api + +// 模态能力必须由**配置层**声明,而不是靠 adapter 的类型断言去猜。 +// +// 现场(反复出现的"配了视觉却说没有视觉"):内核用 +// `provider.(ModalProvider)` 断言判定能力,而该断言要穿过 adapter 与 +// 安全装饰器两层 —— 任一层漏实现方法,能力就静默变成 false。 +// +// 用户指正(2026-10-27):是否支持视觉是**配置事实** +//(core.llm.sources..vision=true),adapter 只负责格式转换。 + +import ( + "context" + "testing" +) + +// plainProvider 故意**不实现** ModalProvider —— 模拟"装饰器漏方法"的情形。 +type plainProvider struct{ name string } + +func (p *plainProvider) Name() string { return p.name } +func (p *plainProvider) MaxContextTokens() int { return 8192 } +func (p *plainProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + return nil, nil +} + +func (p *plainProvider) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { + ch := make(chan StreamChunk) + close(ch) + return ch, nil +} + +func TestProviderManagerCapsDeclaredByConfig(t *testing.T) { + m := NewProviderManager() + p := &plainProvider{name: "proxy"} + m.Register("proxy", p) + + // 断言兜底:plainProvider 不是 ModalProvider ⇒ false + if ProviderSupportsVision(p) { + t.Fatal("前提不成立:plainProvider 不该被断言成支持视觉") + } + // 配置声明后 ⇒ true(这就是"配置层说了算") + m.SetCapabilities("proxy", ProviderCaps{Vision: true}) + if !m.SupportsVision("proxy") { + t.Fatal("配置声明 vision=true 后应判定为支持视觉,不能因为 provider 没实现 ModalProvider 就丢掉能力") + } + if m.SupportsAudio("proxy") { + t.Fatal("未声明 audio 时不应判定支持音频") + } +} + +func TestProviderManagerCapsFallBackToAssertion(t *testing.T) { + m := NewProviderManager() + p := &plainProvider{name: "legacy"} + m.Register("legacy", p) + // 没声明(ok=false)⇒ 退回旧路径的断言结果 + if _, ok := m.Capabilities("legacy"); ok { + t.Fatal("未声明时 Capabilities 应返回 ok=false") + } + if m.SupportsVision("legacy") { + t.Fatal("未声明且非 ModalProvider 时应为 false") + } +} diff --git a/internal/agent/core/modalfallback.go b/internal/agent/core/modalfallback.go index 8ef0cbc5..7e22c272 100644 --- a/internal/agent/core/modalfallback.go +++ b/internal/agent/core/modalfallback.go @@ -250,7 +250,7 @@ func (a *Agent) transcribeBlocksForFallback(blocks []agentAPI.ContentBlock) moda // 为何逐模态判定而不是一刀切:一批块里可能图能看、音频不能听(很多视觉 // 模型就是这样)。全部走回退会白白把本可直视的图降级成二手文字描述。 func (a *Agent) prepareToolBlocks(blocks []agentAPI.ContentBlock) ([]agentAPI.ContentBlock, string) { - canVision := agentAPI.ProviderSupportsVision(a.provider) + canVision := a.providerCanVision() canAudio := agentAPI.ProviderSupportsAudio(a.provider) var native []agentAPI.ContentBlock @@ -358,3 +358,19 @@ func (a *Agent) chatModalFallbackBatch(p agentAPI.Provider, kind string, urls, d } return out, nil } + +// providerCanVision 判定**当前主 provider** 能否看图。 +// +// 先查配置层声明的能力(ProviderManager.Capabilities),配置没说才退回 +// 类型断言。这样 adapter/装饰器漏实现方法也不会让能力凭空消失。 +func (a *Agent) providerCanVision() bool { + if a == nil || a.provider == nil { + return false + } + if a.providerManager != nil { + if _, ok := a.providerManager.Capabilities(a.provider.Name()); ok { + return a.providerManager.SupportsVision(a.provider.Name()) + } + } + return agentAPI.ProviderSupportsVision(a.provider) +} diff --git a/internal/agent/core/process.go b/internal/agent/core/process.go index e71544e7..3aae509c 100644 --- a/internal/agent/core/process.go +++ b/internal/agent/core/process.go @@ -526,6 +526,11 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string { if e.Response != "" { estTokens += 120 } + // 工具回执现在**会**进时间线(见下方渲染),预算必须如实计入, + // 否则"算出来的窗口装得下"与"实际发出去的更长"两笔账对不上。 + for _, tr := range a.renderedToolResultsOf(e) { + estTokens += EstimateTokens(tr) + } if remaining-estTokens < 0 && include > 0 { break } @@ -555,11 +560,79 @@ func (a *Agent) formatMergedTimeline(maxTokens int) string { if e.Response != "" { sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 120))) } + // ★ 工具回执按**声明**渲染(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() } +// renderedToolResults 把一条事件里的工具回执渲染成时间线片段。 +// +// 排序即执行顺序;单条截断 toolResultTimelineChars,最多 toolResultTimelineMax 条 +// (更早的只留"还有 N 条"),避免一次 bulk 查询把时间线挤爆。 +const ( + // toolResultTimelineChars 单条工具回执在时间线里的字符上限。 + // 时间线是"给下一轮看的历史",不是当轮工作区 —— 够让模型认出 + // "我查到了什么",细节仍可用 coldcontext_query / 工具重取。 + toolResultTimelineChars = 160 + // toolResultTimelineMax 单条事件最多渲染几条工具回执。 + toolResultTimelineMax = 3 +) + +// 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() + } + out := make([]string, 0, len(e.ToolResults)) + skipped := 0 + for _, tr := range e.ToolResults { + if noMem[tr.Name] { + 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 + } + out = append(out, fmt.Sprintf("%s: %s", tr.Name, truncateStr(txt, toolResultTimelineChars))) + } + 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}} diff --git a/internal/agent/core/timeline_toolresult_test.go b/internal/agent/core/timeline_toolresult_test.go new file mode 100644 index 00000000..80cec088 --- /dev/null +++ b/internal/agent/core/timeline_toolresult_test.go @@ -0,0 +1,69 @@ +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]) + } +} diff --git a/internal/kernelmod/llm/mod.go b/internal/kernelmod/llm/mod.go index 3f7098ae..c2da73e1 100644 --- a/internal/kernelmod/llm/mod.go +++ b/internal/kernelmod/llm/mod.go @@ -63,6 +63,13 @@ func buildProviderManager(cfg *types.Config, luaVM *luapkg.VM, baseAPIKey string Audio: src.Audio, }, luaVM, src.Name, src.Adapter) providerMgr.Register(src.Name, luaProvider) + // ★ 能力是**配置事实**,由装配层登记;不让内核靠 adapter 上的 + // 类型断言去猜(断言穿过 adapter + 安全装饰器,任一层漏实现方法 + // 就会静默变成"没有视觉"—— 这是反复出现的老 bug)。 + providerMgr.SetCapabilities(src.Name, api.ProviderCaps{ + Vision: src.Vision, + Audio: src.Audio, + }) if src.Adapter != "" { adapterConcurrency[src.Adapter] += src.MaxConcurrent }