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:
JianFeeeee
2026-10-27 11:49:26 +08:00
parent 981b969f58
commit 4af07d08fe
9 changed files with 520 additions and 71 deletions

View 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("替换后的键数上限应生效")
}
}