mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-31 22:37:28 +00:00
## 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
139 lines
4.6 KiB
Go
139 lines
4.6 KiB
Go
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("超过键数上限应报错")
|
||
}
|
||
}
|