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:
1 parent
5ee5f9126f
commit
a30f4a49ff
12 files changed
+1696
-16
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user