mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
fix(memory,llm): L2 预填充并入召回主路 + 回退换 provider 时换 model(视觉失效真因)
## 1. L2(冷上下文)预填充此前只走「注入文本」,等于没有
设计:prune 与 recall 是一对 —— 工具声明 ContextPolicy=prune + RecallPolicy=auto
时,工具输出既触发裁剪、也触发预填充(memoryPass → recallTextFor)。
但 L2 那一路只写在 buildSystemPrompt 里,query 用的是**注入文本**;
QQ 注入的是「来自「X」的私聊消息(message_id=…),先用 qq_get_message 取正文」,
与用户真正说的话毫无相似度 ⇒ 冷上下文预填充恒为空。
(现场:头天晚上讲的冷笑话,第二天早上完全唤不回;14 小时里
memory recall (input…) 只有 5 次、且 query 是元信息。)
改法:把 L2 检索并入 buildMemoryContext —— 输入路用输入文本、工具路用工具正文,
两条路共用同一次召回与预算;buildSystemPrompt 里那份重复注入删掉。
中断消息仍然 NoMemory + RecallPolicy=none(不裁剪、不召回、不进记忆),
这是设计要求,未改动。
## 2. 回退换 provider 时必须换 model(视觉失效的真因)
现场日志:
deepseek 402 Insufficient Balance
→ 回退 llmsproxy,但 req 仍带 model=「deepseek-flash」
→ llmsproxy 403 model_not_allowed
→ llmsproxy 被整源标记 unavailable(冷却)
→ 视觉回退 resolveModalFallback 跳过它 ⇒「配置了视觉却说没有视觉」
两处修正:
· 每个 provider 用自己的 model 发请求(model 是 provider 的属性)
· 403 区分「模型不被该 key 允许」与「凭证失效」:前者只否一个 model,
provider 仍可用于其他 model 与视觉回退
判据:internal/agent/core/provider_model_fallback_test.go
This commit is contained in:
@ -2,7 +2,6 @@ package sdk
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/JianFeeeee/HologramAgent/internal/events"
|
||||
"github.com/JianFeeeee/HologramAgent/internal/security"
|
||||
@ -40,76 +39,75 @@ const (
|
||||
|
||||
// ───────────────────── 受限事件总线(插件可见) ─────────────────────
|
||||
//
|
||||
// 边界由三件事决定,缺一不可:
|
||||
// 1. 类型白名单 + 插件自己的命名空间(`plugin.<name>.*`)
|
||||
// 2. 载荷出向经 security 审查(明文凭据换成 @{别名})+ 键数上限
|
||||
// 3. 订阅只开只读事件(tools_ready / security_ready / device_ready)
|
||||
// **本层只做适配,不做策略**(用户 2026-10-27 的指正):
|
||||
//
|
||||
// 为什么不直接把 *events.Bus 交给插件:Bus.Publish 能发 EventAll 通配类型、
|
||||
// 能伪造任意 Source、载荷不做任何审查。那不是"多给一个便利",而是把内核
|
||||
// 内部同步面变成没有边界的公共写口。
|
||||
|
||||
const (
|
||||
// eventPayloadMaxKeys 限制载荷顶层键数,防止"把一个对象当事件"塞进来。
|
||||
// 事件是**通知**,不是数据通道:大块数据该走插件自己的通道或工具返回值。
|
||||
eventPayloadMaxKeys = 64
|
||||
)
|
||||
// internal/security 谁可以发/订阅什么事件(EventPolicy)+ 载荷过闸
|
||||
// internal/sdk(本文件) 查策略 → 发/订 → 过闸 → 落到 *events.Bus
|
||||
// sdk(公开) 只给接口与事件名
|
||||
//
|
||||
// 为什么策略必须住在 security:事件的边界就是**授权**。能发 skill_detected
|
||||
// 等于能驱动 skillmgr 接管;能发 update_request 等于能触发内核升级流程。
|
||||
// 这类判断与工具黑白名单是同一类,必须与它们同处一处,否则策略会有两份真相,
|
||||
// 换个实现就换口径。
|
||||
|
||||
type eventsAPI struct {
|
||||
pluginName string
|
||||
bus *events.Bus
|
||||
sec *security.Runtime // 可 nil(安全模块未装配时零成本跳过)
|
||||
sec *security.Runtime // 可 nil:未装配时按**默认策略**(最小集合)判定
|
||||
unsubs []func()
|
||||
// unavailable 非空表示宿主没接总线:Publish/Subscribe 一律返回该原因。
|
||||
unavailable string
|
||||
}
|
||||
|
||||
// newEventsAPI 构造插件的受限事件面(bus 为 nil 时返回 nil,调用方按 nil 处理)。
|
||||
// newEventsAPI 构造插件的受限事件面。
|
||||
//
|
||||
// ★ bus 为 nil 时返回**不可用实现**而不是 nil:
|
||||
// 返回 nil 会让 `s.Events().Publish(...)` 在未接总线的宿主里直接 panic
|
||||
// (实测:internal/plugins 的集成测试在 loadOne 阶段崩在 clawhubadapter 上)。
|
||||
// 插件不该为"宿主没接总线"写 nil 判断——那是一个该报错、而不是该崩溃的状态。
|
||||
func newEventsAPI(pluginName string, bus *events.Bus) pubsdk.EventsAPI {
|
||||
api := &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()}
|
||||
if bus == nil {
|
||||
return nil
|
||||
api.unavailable = "event bus is not wired in this host"
|
||||
}
|
||||
return &eventsAPI{pluginName: pluginName, bus: bus, sec: security.RuntimeGlobal()}
|
||||
return api
|
||||
}
|
||||
|
||||
func (e *eventsAPI) AllowedPublish() []string {
|
||||
out := pubsdk.PluginPublishableEvents()
|
||||
pol := e.EventPolicy()
|
||||
out := make([]string, len(pol.Publishable))
|
||||
copy(out, pol.Publishable)
|
||||
return append(out, "plugin."+e.pluginName+".*")
|
||||
}
|
||||
|
||||
func (e *eventsAPI) AllowedSubscribe() []string { return pubsdk.PluginSubscribableEvents() }
|
||||
|
||||
// publishAllowed:白名单精确匹配,或本插件命名空间前缀(plugin.<name>.<后缀>)。
|
||||
func (e *eventsAPI) publishAllowed(t string) bool {
|
||||
for _, a := range pubsdk.PluginPublishableEvents() {
|
||||
if t == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
prefix := "plugin." + e.pluginName + "."
|
||||
return strings.HasPrefix(t, prefix) && len(t) > len(prefix)
|
||||
func (e *eventsAPI) AllowedSubscribe() []string {
|
||||
pol := e.EventPolicy()
|
||||
out := make([]string, len(pol.Subscribable))
|
||||
copy(out, pol.Subscribable)
|
||||
return out
|
||||
}
|
||||
|
||||
func (e *eventsAPI) subscribeAllowed(t string) bool {
|
||||
for _, a := range pubsdk.PluginSubscribableEvents() {
|
||||
if t == a {
|
||||
return true
|
||||
}
|
||||
// EventPolicy 取当前策略:优先用 security Runtime 的(可热改),
|
||||
// Runtime 为 nil 时退回 security 包的默认策略(同样是最小集合,失败方向收紧)。
|
||||
func (e *eventsAPI) EventPolicy() security.EventPolicy {
|
||||
if e.sec != nil {
|
||||
return e.sec.EventPolicy()
|
||||
}
|
||||
return false
|
||||
return security.DefaultEventPolicy()
|
||||
}
|
||||
|
||||
func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) error {
|
||||
if eventType == "" {
|
||||
return fmt.Errorf("events: event type must not be empty")
|
||||
if e.unavailable != "" {
|
||||
return fmt.Errorf("events: %s", e.unavailable)
|
||||
}
|
||||
if !e.publishAllowed(eventType) {
|
||||
return fmt.Errorf("events: plugin %q may not publish %q (allowed: %v)",
|
||||
e.pluginName, eventType, e.AllowedPublish())
|
||||
if d := e.checkPublish(eventType); !d.Allowed {
|
||||
return fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
if payload == nil {
|
||||
payload = map[string]interface{}{}
|
||||
}
|
||||
if len(payload) > eventPayloadMaxKeys {
|
||||
return fmt.Errorf("events: payload has %d keys, limit is %d", len(payload), eventPayloadMaxKeys)
|
||||
if d := e.checkSize(len(payload)); !d.Allowed {
|
||||
return fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
|
||||
// ★ 载荷出向审查:事件载荷会被其他订阅者(含 WebUI/状态面)读到,
|
||||
@ -122,6 +120,27 @@ func (e *eventsAPI) Publish(eventType string, payload map[string]interface{}) er
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkPublish(t string) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventPublish(e.pluginName, t)
|
||||
}
|
||||
return security.DefaultEventPolicy().CheckEventPublish(e.pluginName, t)
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkSubscribe(t string) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventSubscribe(e.pluginName, t)
|
||||
}
|
||||
return security.DefaultEventPolicy().CheckEventSubscribe(e.pluginName, t)
|
||||
}
|
||||
|
||||
func (e *eventsAPI) checkSize(keys int) security.Decision {
|
||||
if e.sec != nil {
|
||||
return e.sec.CheckEventPayloadSize(keys)
|
||||
}
|
||||
return security.DefaultEventPolicy().CheckEventPayloadSize(keys)
|
||||
}
|
||||
|
||||
// scrubOut 对载荷做一次出向脱敏(安全模块未装配时原样返回)。
|
||||
func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) map[string]interface{} {
|
||||
if e.sec == nil {
|
||||
@ -145,9 +164,11 @@ func (e *eventsAPI) scrubOut(eventType string, payload map[string]interface{}) m
|
||||
}
|
||||
|
||||
func (e *eventsAPI) Subscribe(eventType string, handler func(map[string]interface{})) (func(), error) {
|
||||
if !e.subscribeAllowed(eventType) {
|
||||
return nil, fmt.Errorf("events: plugin %q may not subscribe to %q (allowed: %v)",
|
||||
e.pluginName, eventType, e.AllowedSubscribe())
|
||||
if e.unavailable != "" {
|
||||
return nil, fmt.Errorf("events: %s", e.unavailable)
|
||||
}
|
||||
if d := e.checkSubscribe(eventType); !d.Allowed {
|
||||
return nil, fmt.Errorf("%s", d.Reason)
|
||||
}
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("events: handler must not be nil")
|
||||
|
||||
@ -127,8 +127,9 @@ func TestEventsPayloadIsScrubbed(t *testing.T) {
|
||||
// ⑥ 键数上限:事件是通知,不是数据通道。
|
||||
func TestEventsPayloadKeyLimit(t *testing.T) {
|
||||
e, _ := newTestEvents(t, "big")
|
||||
limit := security.DefaultEventPolicy().MaxPayloadKeys
|
||||
p := map[string]interface{}{}
|
||||
for i := 0; i < eventPayloadMaxKeys+1; i++ {
|
||||
for i := 0; i < limit+1; i++ {
|
||||
p[string(rune('a'+i%26))+strings.Repeat("x", i)] = i
|
||||
}
|
||||
if err := e.Publish("skill_detected", p); err == nil {
|
||||
|
||||
Reference in New Issue
Block a user