mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -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 (
|
||||
|
||||
62
internal/agent/api/provider_caps_test.go
Normal file
62
internal/agent/api/provider_caps_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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}}
|
||||
|
||||
|
||||
69
internal/agent/core/timeline_toolresult_test.go
Normal file
69
internal/agent/core/timeline_toolresult_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user