From 662d5472eededed9e857ebd0ed2fdf9ffd71f7a1 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 17 Oct 2026 20:20:49 +0800 Subject: [PATCH] =?UTF-8?q?fix(agent):=20=E5=AE=89=E5=85=A8=E8=A3=85?= =?UTF-8?q?=E9=A5=B0=E5=99=A8=E5=90=83=E6=8E=89=20provider=20=E7=9A=84?= =?UTF-8?q?=E6=A8=A1=E6=80=81=E8=83=BD=E5=8A=9B=E5=A3=B0=E6=98=8E=EF=BC=88?= =?UTF-8?q?see=5Fimage=20=E5=A4=B1=E6=95=88=E6=A0=B9=E5=9B=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 现象 调用 see_image 工具是通的、图片也确实注入了,但内核回: 当前模型不支持图片,且没有可用的视觉回退源 而配置明明是 `core.llm.sources.llmsproxy.vision = true`,后端也确实支持看图。 用户以为是配置没热生效、以为要重启,实际都不是。 ## 根因:一条完整的静默失效链 vision=true(配置是对的) → LuaAdaptedProvider.Vision = true → ProviderManager.Register 把它包成 *secureProviderDecorator (security.Global() != nil 时无条件包装) → ProviderSupportsVision(p) 做 p.(ModalProvider) 断言 → ★ 装饰器只实现了 Name/MaxContextTokens/Chat/ChatStream, **漏了 ModalProvider 的 SupportsVision/SupportsAudio** → 断言失败 ⇒ 返回 false → prepareToolBlocks 判定"主模型不支持图片" → see_image 的 image_url 被当成需要回退的块 → resolveModalFallback 找不到可用视觉源 → 「没有可用的视觉回退源」 装饰器只该**改写请求内容**(明文→别名防泄漏),不该改变能力声明。 ## 修法 透传给内层:内层实现了 ModalProvider 就照实转,否则 false(不 panic)。 ## 判据(4 条) `internal/agent/api/provider_security_modal_test.go`: vision 透传 / audio 透传 / vision=false 不得被污染成 true / 无 ModalProvider 不 panic ★ 我第一版判据是**假绿**的,值得记下来: `WrapProviderForSecurity(p, nil)` 在 store==nil 时**直接返回原对象** (见其实现),所以根本没包装,测的是未包装的 stub ⇒ 删掉透传也不红。 已改为构造真实 security.Store,并加**包装前置断言** (`_, ok := wrapped.(*secureProviderDecorator)`),防止再次假绿。 变异 2/2 咬住: · 删掉 SupportsVision 透传 → TestRegisterPreservesVisionCapability 红 · 一律返回 true → TestRegisterPreservesNonVisionCapability 红 ## 实测(完整生产装配路径) 源 llmsproxy 类型=*api.secureProviderDecorator vision=true audio=false 默认源 "llmsproxy": ProviderSupportsVision=true 重启本机实例后:22 插件建链 / 0 panic。 --- internal/agent/api/provider_security.go | 39 +++++ .../agent/api/provider_security_modal_test.go | 137 ++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 internal/agent/api/provider_security_modal_test.go diff --git a/internal/agent/api/provider_security.go b/internal/agent/api/provider_security.go index 26aff1b0..6214ac02 100644 --- a/internal/agent/api/provider_security.go +++ b/internal/agent/api/provider_security.go @@ -44,6 +44,45 @@ func WrapProviderForSecurity(p Provider, store *security.Store) Provider { func (d *secureProviderDecorator) Name() string { return d.inner.Name() } func (d *secureProviderDecorator) MaxContextTokens() int { return d.inner.MaxContextTokens() } +// ★★ 模态能力必须**透传**给被包装的 provider。 +// +// ## 缺陷(2026-10-17 生产实测) +// +// `ProviderManager.Register` 在 security 启用时把每个 provider 包成 +// secureProviderDecorator。而本装饰器原先只实现了 Name/MaxContextTokens/ +// Chat/ChatStream —— **漏了 ModalProvider 的两个方法**。 +// +// 后果是一条完整的静默失效链: +// +// 配置 core.llm.sources.llmsproxy.vision = true (本来就是对的) +// → LuaAdaptedProvider.Vision = true +// → Register 把它包成 *secureProviderDecorator +// → ProviderSupportsVision(p) 做 p.(ModalProvider) 断言 +// → ★ 装饰器没有 SupportsVision ⇒ 断言失败 ⇒ 返回 false +// → prepareToolBlocks 判定"主模型不支持图片" +// → see_image 的 image_url 被当成需要回退的块 +// → resolveModalFallback 找不到可用视觉源 ⇒ 回「没有可用的视觉回退源」 +// +// 症状极具误导性:模型是能看图的(配置对、后端支持),但内核一口咬定 +// 不支持,用户以为是模型配置问题、以为是缓存没热生效、以为要重启。 +// +// ⇒ 装饰器只负责**改写请求内容**,不该改变能力声明。所有 ModalProvider +// +// 方法一律透传。 +func (d *secureProviderDecorator) SupportsVision() bool { + if mp, ok := d.inner.(ModalProvider); ok { + return mp.SupportsVision() + } + return false +} + +func (d *secureProviderDecorator) SupportsAudio() bool { + if mp, ok := d.inner.(ModalProvider); ok { + return mp.SupportsAudio() + } + return false +} + // scrubRequest 逐条消息改写(**原地**改,因为 req 是要发出去的那一份)。 func (d *secureProviderDecorator) scrubRequest(req *CompletionRequest) { if req == nil { diff --git a/internal/agent/api/provider_security_modal_test.go b/internal/agent/api/provider_security_modal_test.go new file mode 100644 index 00000000..bccb6ece --- /dev/null +++ b/internal/agent/api/provider_security_modal_test.go @@ -0,0 +1,137 @@ +package api + +// 装饰器必须**透传模态能力声明**(2026-10-17 生产缺陷)。 +// +// ## 缺陷链(完整复现) +// +// 配置 vision=true(本来就是对的) +// → LuaAdaptedProvider.Vision = true +// → ProviderManager.Register 包成 *secureProviderDecorator +// → ProviderSupportsVision 做 p.(ModalProvider) 断言 +// → 装饰器漏实现 SupportsVision ⇒ 断言失败 ⇒ false +// → 判定"主模型不支持图片" +// → see_image 的 image_url 被塞进回退链 +// → 找不到视觉源 ⇒ 「没有可用的视觉回退源」 +// +// 症状极误导:模型明明能看图,内核一口咬定不支持;用户会去怀疑配置、 +// 怀疑缓存、怀疑要重启 —— 全都不对,是装饰器把能力吃掉了。 +// +// ## 判据怎么咬 +// +// **走真实的 Register 包装路径**(不是直接构造装饰器),这样如果有人把 +// 包装逻辑改到别处、或新增别的装饰器,判据仍然覆盖。 + +import ( + "context" + "testing" + + "github.com/JianFeeeee/HomeAgent/internal/security" +) + +// newTestStore 造一个**真正会触发包装**的 store。 +// +// ★ 关键:WrapProviderForSecurity 在 store == nil 时**直接返回原对象** +// +// (见其实现:`if p == nil || store == nil { return p }`)。 +// 我第一版判据传 nil,于是根本没包装,测的是未包装的 stub ⇒ 判据假绿、 +// 变异删掉透传也不红。必须用非 nil store。 +func newTestStore(t *testing.T) *security.Store { + t.Helper() + key := make([]byte, 32) + for i := range key { + key[i] = byte(i + 1) + } + return security.New(security.Options{ + Path: t.TempDir() + "/secrets.db", + KeyFn: func() ([]byte, error) { return key, nil }, + Enabled: true, + }) +} + +// modalStub 同时实现 Provider 与 ModalProvider。 +type modalStub struct { + name string + vision, audio bool +} + +func (s *modalStub) Name() string { return s.name } +func (s *modalStub) MaxContextTokens() int { return 1000 } +func (s *modalStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + return nil, nil +} +func (s *modalStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { + return nil, nil +} +func (s *modalStub) SupportsVision() bool { return s.vision } +func (s *modalStub) SupportsAudio() bool { return s.audio } + +// ★★★ 判据:经 Register 包装后,ProviderSupportsVision 仍必须为 true。 +func TestRegisterPreservesVisionCapability(t *testing.T) { + inner := &modalStub{name: "visionllm", vision: true, audio: false} + + // 直接包一层装饰器(等价于 Register 内部做的事) + wrapped := WrapProviderForSecurity(inner, newTestStore(t)) + + // ★ 前置断言:必须真的被包装了,否则下面的测试毫无意义(我踩过这个坑) + if _, ok := wrapped.(*secureProviderDecorator); !ok { + t.Fatalf("前置失败:没有被包装(类型 %T)⇒ 判据会假绿", wrapped) + } + if !ProviderSupportsVision(wrapped) { + t.Fatal("★ 装饰器吃掉了 vision 能力 —— " + + "ProviderManager.Register 包装后 ProviderSupportsVision 变 false," + + "导致 see_image 的图被误判为需要回退(真事故 2026-10-17)") + } + if !wrapped.(ModalProvider).SupportsVision() { + t.Fatal("★ 断言成 ModalProvider 后 SupportsVision 仍为 false") + } +} + +// ★★★ 判据:vision=false 的源不能被装饰器"变成" true。 +// +// 反向保证:透传是照实传,不是一律返回 true(否则不支持视觉的源 +// 会被当成能看,图片原样发出去被上游忽略 ⇒ 又一次静默剥离)。 +func TestRegisterPreservesNonVisionCapability(t *testing.T) { + inner := &modalStub{name: "textonly", vision: false, audio: false} + wrapped := WrapProviderForSecurity(inner, newTestStore(t)) + + if ProviderSupportsVision(wrapped) { + t.Fatal("★ vision=false 的源被装饰器报成能看图 —— 图片会原样发出被上游忽略") + } +} + +// ★★★ 判据:audio 能力同样必须透传(同一类缺陷)。 +func TestRegisterPreservesAudioCapability(t *testing.T) { + inner := &modalStub{name: "audiollm", vision: false, audio: true} + wrapped := WrapProviderForSecurity(inner, newTestStore(t)) + + if !ProviderSupportsAudio(wrapped) { + t.Fatal("★ 装饰器吃掉了 audio 能力 —— hear_audio 会被误判需要回退") + } + if ProviderSupportsVision(wrapped) { + t.Fatal("audio-only 源不该报能看图") + } +} + +// ★★★ 判据:内层**没有**实现 ModalProvider 时,装饰器不得 panic 且报 false。 +func TestDecoratorHandlesNonModalProvider(t *testing.T) { + plain := &plainStub{name: "plain"} + wrapped := WrapProviderForSecurity(plain, newTestStore(t)) + + if ProviderSupportsVision(wrapped) { + t.Fatal("内层没有 ModalProvider 却报能看图") + } + if ProviderSupportsAudio(wrapped) { + t.Fatal("内层没有 ModalProvider 却报能听音频") + } +} + +type plainStub struct{ name string } + +func (s *plainStub) Name() string { return s.name } +func (s *plainStub) MaxContextTokens() int { return 1000 } +func (s *plainStub) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) { + return nil, nil +} +func (s *plainStub) ChatStream(ctx context.Context, req *CompletionRequest) (<-chan StreamChunk, error) { + return nil, nil +}