fix(memory): 时间线按声明渲染工具回执 + 能力判定改为配置层

时间线此前只写「→ 调用工具: 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:可读不写)
This commit is contained in:
JianFeeeee
2026-10-27 12:16:06 +08:00
parent 4af07d08fe
commit 051247e10f
6 changed files with 290 additions and 1 deletions

View File

@ -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.<name>.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 (

View File

@ -0,0 +1,62 @@
package api
// 模态能力必须由**配置层**声明,而不是靠 adapter 的类型断言去猜。
//
// 现场(反复出现的"配了视觉却说没有视觉"):内核用
// `provider.(ModalProvider)` 断言判定能力,而该断言要穿过 adapter 与
// 安全装饰器两层 —— 任一层漏实现方法,能力就静默变成 false。
//
// 用户指正(2026-10-27):是否支持视觉是**配置事实**
//(core.llm.sources.<name>.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")
}
}

View File

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

View File

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

View File

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

View File

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