Files
HomeAgent/internal/sdk/events_test.go
JianFeeeee 4af07d08fe 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
2026-10-27 11:49:26 +08:00

139 lines
4.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package sdk
// 受限事件总线的判据。
//
// 设计前提(用户 2026-10-27):现在有 security 模块兜底,可以给插件开事件总线,
// 但只能是**受限的一条缝**——白名单类型、自己的命名空间、载荷过闸。
// 每条判据都在问:插件能不能借事件总线做超出边界的事?
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/security"
pubsdk "github.com/JianFeeeee/hologramagentsdk/sdk"
)
func newTestEvents(t *testing.T, name string) (pubsdk.EventsAPI, *events.Bus) {
t.Helper()
bus := events.NewBus()
return newEventsAPI(name, bus), bus
}
// ① 白名单事件可发布,且 Source 被强制设成插件名(不能冒名)。
func TestEventsPublishAllowlisted(t *testing.T) {
e, bus := newTestEvents(t, "clawhubadapter")
var got *events.Event
bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { got = evt })
if err := e.Publish("skill_detected", map[string]interface{}{"path": "/x/SKILL.md"}); err != nil {
t.Fatalf("白名单事件应允许发布: %v", err)
}
if got == nil {
t.Fatal("订阅者没收到事件")
}
if got.Source != "clawhubadapter" {
t.Fatalf("Source 必须是插件名(不能冒名),实际 %q", got.Source)
}
}
// ② 越界类型必须**报错**(静默丢弃会让插件以为发成功)。
func TestEventsPublishRejectsNonAllowlisted(t *testing.T) {
e, _ := newTestEvents(t, "evil")
for _, bad := range []string{
"raw_input", // 用户输入通道
"agent_output", // 对外输出
"update_request", // 能驱动内核升级流程
"stage", // 阶段管道内部事件
"system", // 系统事件
"plugin.other.x", // 别的插件命名空间
"plugin.evil", // 缺少后缀
"", // 空类型
} {
if err := e.Publish(bad, nil); err == nil {
t.Errorf("发布 %q 应被拒绝", bad)
}
}
}
// ③ 自己的命名空间允许(插件间协作),且带后缀。
func TestEventsPublishOwnNamespace(t *testing.T) {
e, bus := newTestEvents(t, "seq")
var seen bool
bus.Subscribe(events.EventType("plugin.seq.done"), func(*events.Event) { seen = true })
if err := e.Publish("plugin.seq.done", map[string]interface{}{"n": 1}); err != nil {
t.Fatalf("自己的命名空间应允许: %v", err)
}
if !seen {
t.Fatal("订阅者没收到自己的命名空间事件")
}
}
// ④ 订阅只开只读事件;越界订阅报错。
func TestEventsSubscribeAllowlistedOnly(t *testing.T) {
e, bus := newTestEvents(t, "p1")
if _, err := e.Subscribe("tools_ready", func(map[string]interface{}) {}); err != nil {
t.Fatalf("tools_ready 应可订阅: %v", err)
}
for _, bad := range []string{"raw_input", "agent_output", "update_request"} {
if _, err := e.Subscribe(bad, func(map[string]interface{}) {}); err == nil {
t.Errorf("订阅 %q 应被拒绝", bad)
}
}
// 解除订阅后不再收到
cancel, err := e.Subscribe("security_ready", func(map[string]interface{}) {})
if err != nil || cancel == nil {
t.Fatalf("应能订阅 security_ready: %v", err)
}
cancel()
if bus == nil {
t.Fatal("bus 不应为 nil")
}
}
// ⑤ 载荷出向过安全闸:明文凭据被换成 @{别名},不会原样进事件。
func TestEventsPayloadIsScrubbed(t *testing.T) {
dir := t.TempDir()
store, err := security.InitGlobal(security.Options{
Path: dir + "/vars.json",
KeyFn: func() ([]byte, error) { return []byte("0123456789abcdef0123456789abcdef"), nil },
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { security.SetGlobal(nil) })
security.SetRuntimeGlobal(security.NewRuntime(store))
t.Cleanup(func() { security.SetRuntimeGlobal(nil) })
e, bus := newTestEvents(t, "leaky")
var payload map[string]interface{}
bus.Subscribe(events.EventSkillDetected, func(evt *events.Event) { payload = evt.Payload })
const secret = "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
if err := e.Publish("skill_detected", map[string]interface{}{"note": "token=" + secret}); err != nil {
t.Fatal(err)
}
s, _ := payload["note"].(string)
if strings.Contains(s, secret) {
t.Fatalf("载荷里的明文凭据没有被拦下: %q", s)
}
if !strings.Contains(s, "@{sec_") {
t.Fatalf("应换成变量别名,实际 %q", s)
}
}
// ⑥ 键数上限:事件是通知,不是数据通道。
func TestEventsPayloadKeyLimit(t *testing.T) {
e, _ := newTestEvents(t, "big")
limit := security.DefaultEventPolicy().MaxPayloadKeys
p := map[string]interface{}{}
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 {
t.Fatal("超过键数上限应报错")
}
}