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

+38
View File
@@ -237,6 +237,44 @@ func (r *Registry) RegisterTool(t sdk.Tool) error {
})
}
// ReplaceTool 用**同名**的 t 就地换掉已登记的工具条目(热替换,design/09 §5.5:
// 面板保存后下一轮即用新接线,不必重启)。
//
// 与「Remove + Register」的两处实质差别,正是这个窄方法存在的理由:
// - **原子**:替换在一次写锁内完成,读者中间看不到「这个名字不存在」的空窗
// (Remove+Register 之间若有 Invoke 落到这个名字上,会得到 ErrUnknownTool);
// - **保留 Policy**:分组/陌生人的可达标记是**运行期可改的配置**(§11 §3),
// 不属于工具实现。初始注册时带的标记没有进 persisted 快照,Remove+Register
// 会把它悄悄重置成零值(= 退回 owner-only),用户会看到「面板保存后某个群
// 忽然不能用了」。这里原样沿用内存里的既有标记。
//
// 没有同名条目时返回 ErrNotFound(**不**顺手新增:热替换的语义是「换掉现在这把」,
// 名字打错时静默插入会掩盖调用方的错);既有条目不是 KindTool 时返回 ErrInvalidArgument。
func (r *Registry) ReplaceTool(t sdk.Tool) error {
if t == nil || t.Name() == "" {
return fmt.Errorf("registry: replace tool: %w", ErrInvalidArgument)
}
name := t.Name()
r.mu.Lock()
defer r.mu.Unlock()
cur, ok := r.caps[name]
if !ok {
return fmt.Errorf("registry: replace %q: %w", name, ErrNotFound)
}
if cur.Kind != KindTool {
return fmt.Errorf("registry: replace %q: 既有能力类型是 %q,不是工具: %w", name, cur.Kind, ErrInvalidArgument)
}
r.caps[name] = Capability{
Name: name,
Kind: KindTool,
Description: t.Description(),
// Policy 沿用旧条目的:它是可编辑的运行期配置,不属于工具的构造产出。
Policy: cur.Policy,
Value: t,
}
return nil
}
// RegisterSkill registers s as a KindSkill capability.
func (r *Registry) RegisterSkill(s sdk.Skill) error {
if s == nil {
@@ -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)
}
}