feat(search): provider 热重载 + primary/fallback(W3)

- 保存被包成事务:快照 → 落盘 → 按新配置重建工具 → 原子替换注册表条目(registry.ReplaceTool
  窄方法,保留运行期 Policy);下一轮即生效,不要求重启
- 若新配置会让后端下次起不来(主家未配齐 fail-fast)⇒ 文件按字节回滚 + 409 config_unstartable
- 声明面补 primary + fallback[](可配可生效);否决「另开 form」方案(会破坏既有 reauth 回填)
- 主 provider 解析口径核查结论:没被 PLG-4 改坏(顺手修了一处日志缺陷)
- 4 个变异自检(丢 Policy/不换条目/回滚 no-op/忽略 fallback)
This commit is contained in:
AskaEth committed 2026-10-06 15:59:12 +08:00
1 parent 5ee5f9126f
commit a30f4a49ff
12 files changed
+1696 -16

No files matched your search

@@ -0,0 +1,101 @@
package registry
import (
"context"
"errors"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件钉住切片 W3 起的**窄方法** `ReplaceTool`(热替换,design/09 §5.5):
// 面板保存后重建出来的同名工具要能就地换掉旧条目,且换完不丢「用户改过的策略」。
//
// 单独成文件的理由:registry_test.go 在 HEAD 上就没过 gofmt(存量),
// 本片不动它(不顺手格式化会产生与主题无关的 diff)。
// stubTool 是一个名字可指定的最小工具(只用于注册/替换的判定)。
type stubTool struct {
name string
desc string
}
func (s stubTool) Name() string { return s.name }
func (s stubTool) Description() string { return s.desc }
func (s *stubTool) Parameters() (map[string]any, error) {
return map[string]any{"type": "object"}, nil
}
func (s *stubTool) Execute(_ context.Context, _ any) (any, error) { return nil, nil }
var _ sdk.Tool = (*stubTool)(nil)
// TestReplaceToolSwapsValueAndKeepsPolicy 是热替换的主判据:
// - Value 换成新工具(调用方随后 Invoke 拿到的就是新的那一把);
// - Description 跟着新工具走(它是工具实现的事实);
// - **Policy 原样保留** —— 分组/陌生人可达标记是运行期配置,不属于工具实现;
// 丢掉它 = 一次面板保存让某个群忽然不能用了。
func TestReplaceToolSwapsValueAndKeepsPolicy(t *testing.T) {
r := New()
if err := r.RegisterTool(&stubTool{name: "web-search", desc: "old"}); err != nil {
t.Fatalf("register: %v", err)
}
if err := r.SetAllowInGroup("web-search", true); err != nil {
t.Fatalf("SetAllowInGroup: %v", err)
}
next := &stubTool{name: "web-search", desc: "new"}
if err := r.ReplaceTool(next); err != nil {
t.Fatalf("ReplaceTool: %v", err)
}
cap, err := r.Get("web-search")
if err != nil {
t.Fatalf("Get: %v", err)
}
if cap.Value != sdk.Tool(next) {
t.Fatalf("替换后 Value = %#v, want 新的那一把", cap.Value)
}
if cap.Description != "new" {
t.Fatalf("替换后 Description = %q, want %q(描述属于工具实现,跟着换)", cap.Description, "new")
}
if !cap.Policy.AllowInGroup {
t.Fatal("替换丢掉了 AllowInGroup:运行期策略不属于工具构造产出,必须沿用")
}
if cap.Kind != KindTool {
t.Fatalf("替换后 Kind = %q, want %q", cap.Kind, KindTool)
}
}
// TestReplaceToolReportsMissingAndInvalid:两种**不该被静默吞掉**的调用错误。
func TestReplaceToolReportsMissingAndInvalid(t *testing.T) {
r := New()
err := r.ReplaceTool(&stubTool{name: "web-search"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("替换不存在的名字 err = %v, want ErrNotFound(不顺手新增:名字打错时静默插入会掩盖调用方的错)", err)
}
if err := r.RegisterTool(&stubTool{name: "nope"}); err != nil {
t.Fatalf("register: %v", err)
}
if err := r.ReplaceTool(nil); !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("替换成 nil err = %v, want ErrInvalidArgument", err)
}
}
// TestReplaceToolRefusesNonToolEntry:同名条目不是工具时拒绝,绝不悄悄改掉它的 Kind
// (把一条渠道/MCP 能力换成工具会让「这条能力是什么」变成一件靠时间猜的事)。
func TestReplaceToolRefusesNonToolEntry(t *testing.T) {
r := New()
if err := r.Register(Capability{Name: "web-search", Kind: KindMCP, Value: struct{}{}}); err != nil {
t.Fatalf("register: %v", err)
}
err := r.ReplaceTool(&stubTool{name: "web-search"})
if !errors.Is(err, ErrInvalidArgument) {
t.Fatalf("err = %v, want ErrInvalidArgument", err)
}
cap, gerr := r.Get("web-search")
if gerr != nil {
t.Fatalf("Get: %v", gerr)
}
if cap.Kind != KindMCP {
t.Fatalf("被拒的替换却改了 Kind:%q", cap.Kind)
}
}