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:
147
internal/security/eventpolicy.go
Normal file
147
internal/security/eventpolicy.go
Normal file
@ -0,0 +1,147 @@
|
||||
package security
|
||||
|
||||
// 事件总线策略:**谁能发/订阅什么事件**。
|
||||
//
|
||||
// ## 为什么它属于 security 模块(用户 2026-10-27)
|
||||
//
|
||||
// 事件的边界不是"总线的一个参数",而是**授权**:一个插件能发 skill_detected
|
||||
// 就等于能驱动 skillmgr 接管;能发 update_request 就等于能触发内核升级流程。
|
||||
// 这类判断和工具黑白名单是同一类东西,理应放在同一处(security),而不是
|
||||
// 散在 SDK 实现里 —— 否则策略会出现两份真相,且换实现就换口径。
|
||||
//
|
||||
// 分工:
|
||||
//
|
||||
// internal/security 策略(本文件)+ 载荷过闸(PrepareOutbound/ScrubInbound)
|
||||
// internal/sdk 总线适配:查策略 → 发/订 → 过闸
|
||||
// sdk(公开) 只给接口与事件名,不给策略
|
||||
//
|
||||
// ## 为什么"关闭 security"不影响本策略
|
||||
//
|
||||
// `core.security.enabled=false` 关掉的是**凭据替换**(需要主密钥),
|
||||
// 而"插件不许发 raw_input"这类授权与密钥无关。所以 EventPolicy 挂在
|
||||
// Runtime 上,Disabled Runtime 同样持有默认策略 —— 关安全不等于开权限。
|
||||
|
||||
import "strings"
|
||||
|
||||
// EventPolicy 是事件总线的授权策略。
|
||||
type EventPolicy struct {
|
||||
// Publishable 是插件可发布的事件类型白名单。
|
||||
//
|
||||
// ★ 加一项前先问:这个事件会不会让插件**间接驱动内核行为**?
|
||||
// 会的话就不能加——事件是通知,不是未授权的执行入口。
|
||||
Publishable []string
|
||||
// Subscribable 是插件可订阅的**只读**事件白名单。
|
||||
Subscribable []string
|
||||
// MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。
|
||||
MaxPayloadKeys int
|
||||
}
|
||||
|
||||
// DefaultEventPolicy 是当前的默认授权表。
|
||||
func DefaultEventPolicy() EventPolicy {
|
||||
return EventPolicy{
|
||||
Publishable: []string{
|
||||
// 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给
|
||||
// skillmgr 接管:这是**职责链交接**,不是执行指令。
|
||||
"skill_detected",
|
||||
},
|
||||
Subscribable: []string{
|
||||
// tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点)
|
||||
"tools_ready",
|
||||
// security_ready:安全闸门装配完成(含"是否真的在生效")
|
||||
"security_ready",
|
||||
// device_ready:工具/设备数据面就绪
|
||||
"device_ready",
|
||||
},
|
||||
MaxPayloadKeys: 64,
|
||||
}
|
||||
}
|
||||
|
||||
// pluginNamespacePrefix 返回某个插件**自己的**事件命名空间前缀。
|
||||
//
|
||||
// 插件的私有事件必须在这个前缀下(`plugin.<name>.<后缀>`),
|
||||
// 这样"两个插件协作"不需要新增白名单,也不可能冒用别人的名字。
|
||||
func pluginNamespacePrefix(plugin string) string {
|
||||
return "plugin." + plugin + "."
|
||||
}
|
||||
|
||||
// CheckEventPublish 判定插件能否发布某个事件。
|
||||
func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision {
|
||||
if plugin == "" {
|
||||
return deny("events: publisher name is empty")
|
||||
}
|
||||
if eventType == "" {
|
||||
return deny("events: event type must not be empty")
|
||||
}
|
||||
for _, a := range p.Publishable {
|
||||
if eventType == a {
|
||||
return allowed()
|
||||
}
|
||||
}
|
||||
prefix := pluginNamespacePrefix(plugin)
|
||||
if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) {
|
||||
return allowed()
|
||||
}
|
||||
return deny("events: plugin %q may not publish %q (allowlist: %v, own namespace: %s*)",
|
||||
plugin, eventType, p.Publishable, prefix)
|
||||
}
|
||||
|
||||
// CheckEventSubscribe 判定插件能否订阅某个事件。
|
||||
func (p EventPolicy) CheckEventSubscribe(plugin, eventType string) Decision {
|
||||
if plugin == "" {
|
||||
return deny("events: subscriber name is empty")
|
||||
}
|
||||
for _, a := range p.Subscribable {
|
||||
if eventType == a {
|
||||
return allowed()
|
||||
}
|
||||
}
|
||||
return deny("events: plugin %q may not subscribe to %q (allowlist: %v)",
|
||||
plugin, eventType, p.Subscribable)
|
||||
}
|
||||
|
||||
// CheckEventPayloadSize 判定载荷规模(键数)是否在允许范围。
|
||||
func (p EventPolicy) CheckEventPayloadSize(keys int) Decision {
|
||||
max := p.MaxPayloadKeys
|
||||
if max <= 0 {
|
||||
max = 64
|
||||
}
|
||||
if keys > max {
|
||||
return deny("events: payload has %d keys, limit is %d", keys, max)
|
||||
}
|
||||
return allowed()
|
||||
}
|
||||
|
||||
// ───────────────────── Runtime 上的对外入口 ─────────────────────
|
||||
|
||||
// EventPolicy 返回当前事件策略(拷贝,调用方可安全读取)。
|
||||
func (r *Runtime) EventPolicy() EventPolicy {
|
||||
if r == nil {
|
||||
return DefaultEventPolicy()
|
||||
}
|
||||
return r.eventPolicy
|
||||
}
|
||||
|
||||
// SetEventPolicy 替换事件策略(配置重载时调用)。
|
||||
func (r *Runtime) SetEventPolicy(p EventPolicy) {
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
r.eventPolicy = p
|
||||
}
|
||||
|
||||
// CheckEventPublish / CheckEventSubscribe / CheckEventPayloadSize 是内核侧
|
||||
// (internal/sdk 的事件适配层)唯一该调的入口。
|
||||
//
|
||||
// nil Runtime 时退回默认策略 —— **失败方向是收紧**(默认表就是最小集合),
|
||||
// 不是放行。
|
||||
func (r *Runtime) CheckEventPublish(plugin, eventType string) Decision {
|
||||
return r.EventPolicy().CheckEventPublish(plugin, eventType)
|
||||
}
|
||||
|
||||
func (r *Runtime) CheckEventSubscribe(plugin, eventType string) Decision {
|
||||
return r.EventPolicy().CheckEventSubscribe(plugin, eventType)
|
||||
}
|
||||
|
||||
func (r *Runtime) CheckEventPayloadSize(keys int) Decision {
|
||||
return r.EventPolicy().CheckEventPayloadSize(keys)
|
||||
}
|
||||
102
internal/security/eventpolicy_test.go
Normal file
102
internal/security/eventpolicy_test.go
Normal file
@ -0,0 +1,102 @@
|
||||
package security
|
||||
|
||||
// 事件总线**授权策略**的判据。
|
||||
//
|
||||
// 用户 2026-10-27 的指正:白名单放行 / 越界拒绝属于**策略**,必须在 security
|
||||
// 模块里,而不是散在 SDK 适配层。所以这些判据钉在 security 包上 ——
|
||||
// 换实现(谁的适配层)都不能改变口径。
|
||||
|
||||
import "testing"
|
||||
|
||||
// ① 白名单内的事件可发布。
|
||||
func TestEventPolicyAllowsListedPublish(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
if d := p.CheckEventPublish("clawhubadapter", "skill_detected"); !d.Allowed {
|
||||
t.Fatalf("白名单事件应放行: %s", d.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
// ② 越界类型必须拒绝,且理由里带白名单(否则调用方只能猜)。
|
||||
func TestEventPolicyRejectsUnlistedPublish(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
for _, bad := range []string{
|
||||
"raw_input", // 用户输入
|
||||
"agent_output", // 对外输出
|
||||
"update_request", // 能驱动升级流程
|
||||
"stage", // 阶段管道内部
|
||||
"system", // 系统事件
|
||||
"", // 空
|
||||
} {
|
||||
d := p.CheckEventPublish("any", bad)
|
||||
if d.Allowed {
|
||||
t.Errorf("发布 %q 应被拒绝", bad)
|
||||
}
|
||||
if d.Reason == "" {
|
||||
t.Errorf("拒绝 %q 时必须给出理由", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ③ 自己的命名空间允许(插件间协作),且必须有后缀、不能冒用别人。
|
||||
func TestEventPolicyOwnNamespace(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
cases := []struct {
|
||||
plugin, event string
|
||||
want bool
|
||||
}{
|
||||
{"seq", "plugin.seq.done", true},
|
||||
{"seq", "plugin.seq.", false}, // 只有前缀没有后缀
|
||||
{"seq", "plugin.seq", false}, // 没有点
|
||||
{"seq", "plugin.other.done", false}, // 冒用别人
|
||||
{"", "plugin.x.done", false}, // 没有发布者名
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := p.CheckEventPublish(c.plugin, c.event).Allowed; got != c.want {
|
||||
t.Errorf("plugin=%q event=%q 期望 %v 实际 %v", c.plugin, c.event, c.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ④ 订阅只开只读事件。
|
||||
func TestEventPolicySubscribeReadOnlyOnly(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
for _, ok := range []string{"tools_ready", "security_ready", "device_ready"} {
|
||||
if d := p.CheckEventSubscribe("p1", ok); !d.Allowed {
|
||||
t.Errorf("%q 应可订阅: %s", ok, d.Reason)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"raw_input", "agent_output", "update_request"} {
|
||||
if d := p.CheckEventSubscribe("p1", bad); d.Allowed {
|
||||
t.Errorf("订阅 %q 应被拒绝", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ⑤ 载荷键数上限(事件是通知,不是数据通道)。
|
||||
func TestEventPolicyPayloadKeyLimit(t *testing.T) {
|
||||
p := DefaultEventPolicy()
|
||||
if d := p.CheckEventPayloadSize(p.MaxPayloadKeys); !d.Allowed {
|
||||
t.Fatalf("达到上限应放行: %s", d.Reason)
|
||||
}
|
||||
if d := p.CheckEventPayloadSize(p.MaxPayloadKeys + 1); d.Allowed {
|
||||
t.Fatal("超过上限应拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
// ⑥ Runtime 委托:策略挂在 Runtime 上,可替换;Disabled Runtime 也带默认策略
|
||||
//
|
||||
// —— 关掉凭据替换不等于放开事件授权。
|
||||
func TestRuntimeEventPolicyDelegation(t *testing.T) {
|
||||
r := NewDisabledRuntime()
|
||||
if d := r.CheckEventPublish("p", "raw_input"); d.Allowed {
|
||||
t.Fatal("Disabled Runtime 仍应拒绝越界事件(关安全≠开权限)")
|
||||
}
|
||||
custom := EventPolicy{Publishable: []string{"my_event"}, MaxPayloadKeys: 1}
|
||||
r.SetEventPolicy(custom)
|
||||
if d := r.CheckEventPublish("p", "my_event"); !d.Allowed {
|
||||
t.Fatalf("替换策略后应放行: %s", d.Reason)
|
||||
}
|
||||
if d := r.CheckEventPayloadSize(2); d.Allowed {
|
||||
t.Fatal("替换后的键数上限应生效")
|
||||
}
|
||||
}
|
||||
@ -57,6 +57,12 @@ type Runtime struct {
|
||||
profiles *profileRegistry
|
||||
// active 记录**当前活跃档位**(由 inputch 输入的 SecurityProfile 选择)。
|
||||
active *profileActivator
|
||||
// eventPolicy 是**事件总线授权表**(谁能发/订阅什么事件)。
|
||||
//
|
||||
// ★ 它与凭据闸门无关:`core.security.enabled=false` 关掉的是凭据替换,
|
||||
// 而"插件不许发 raw_input"这类授权始终生效 —— 所以 Disabled Runtime
|
||||
// 同样持有默认策略(见 NewDisabledRuntime)。
|
||||
eventPolicy EventPolicy
|
||||
// principals 是**按主体**的能力策略(插件声明,内核执行)。
|
||||
//
|
||||
// ★ 它替代的正是"插件自己在 stage 里拦工具"那种实现
|
||||
@ -71,13 +77,14 @@ func NewRuntime(store *Store) *Runtime {
|
||||
return NewDisabledRuntime()
|
||||
}
|
||||
return &Runtime{
|
||||
gate: NewGate(store),
|
||||
enabled: true,
|
||||
policy: DefaultPolicy(),
|
||||
scopes: NewScopeRegistry(),
|
||||
principals: newPrincipalRegistry(),
|
||||
profiles: newProfileRegistry(),
|
||||
active: &profileActivator{},
|
||||
gate: NewGate(store),
|
||||
enabled: true,
|
||||
policy: DefaultPolicy(),
|
||||
eventPolicy: DefaultEventPolicy(),
|
||||
scopes: NewScopeRegistry(),
|
||||
principals: newPrincipalRegistry(),
|
||||
profiles: newProfileRegistry(),
|
||||
active: &profileActivator{},
|
||||
}
|
||||
}
|
||||
|
||||
@ -86,12 +93,13 @@ func NewDisabledRuntime() *Runtime {
|
||||
// 即使禁用也装一个策略表:否则"关闭凭据防火墙"会连带让工具策略失效,
|
||||
// 而这是两件不同的事(用户可以只要策略、不要凭据替换)。
|
||||
return &Runtime{
|
||||
enabled: false,
|
||||
policy: DefaultPolicy(),
|
||||
scopes: NewScopeRegistry(),
|
||||
principals: newPrincipalRegistry(),
|
||||
profiles: newProfileRegistry(),
|
||||
active: &profileActivator{},
|
||||
enabled: false,
|
||||
policy: DefaultPolicy(),
|
||||
eventPolicy: DefaultEventPolicy(),
|
||||
scopes: NewScopeRegistry(),
|
||||
principals: newPrincipalRegistry(),
|
||||
profiles: newProfileRegistry(),
|
||||
active: &profileActivator{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user