Details (original message): Integrate the agent-intent gate: plugin intent overlays rebuild the registry (fail-closed, keeps previous snapshot), HITL free-input pivots on matched foreign intents; unit and e2e tests; pin logging-system and error-code-system submodules.
292 lines
8.9 KiB
Go
292 lines
8.9 KiB
Go
// Package plugins 实现插件宿主(4.6):manifest + 声明资产 + 可选代码。
|
||
// fail-closed:任一资产解析失败整批拒绝该插件并保留上一状态;卸载注销注册项。
|
||
// 声明资产热加载:Reload 重扫目录;工具/面板/卡片/意图/工作流/设置全部数据化。
|
||
package plugins
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
|
||
core "agent-system-core"
|
||
"agent-system-backend/internal/settings"
|
||
"agent-system-backend/internal/store"
|
||
"agent-system-backend/internal/tools"
|
||
)
|
||
|
||
// Loaded 是一个已装载插件的内存态(注册结果快照)。
|
||
type Loaded struct {
|
||
Manifest core.PluginManifest
|
||
Dir string
|
||
Panels []core.PluginPanel
|
||
Cards map[string][]core.UINode // kind → 模板节点
|
||
Workflows []core.WorkflowDef
|
||
Intents []json.RawMessage // agent-intent overlay 形态(目录侧消费)
|
||
Prompts map[string]string // 文件名 → 内容(画像模板)
|
||
PluginSettings []core.SettingDesc
|
||
Defaults map[string]any // 插件设置默认值(并入有效设置)
|
||
}
|
||
|
||
// Host 管理插件目录装载与注册。
|
||
type Host struct {
|
||
mu sync.RWMutex
|
||
st *store.Store
|
||
registry *tools.Registry
|
||
settings *settings.Service
|
||
dir string
|
||
loaded map[string]*Loaded // id → loaded
|
||
// OnIntents 在装载集合变化后回调全部意图 overlay(宿主接识别闸门;nil = 不接)。
|
||
OnIntents func(overlay []json.RawMessage) error
|
||
}
|
||
|
||
func New(st *store.Store, reg *tools.Registry, s *settings.Service, dir string) *Host {
|
||
return &Host{st: st, registry: reg, settings: s, dir: dir, loaded: map[string]*Loaded{}}
|
||
}
|
||
|
||
// LoadDir 全量装载目录(启动与热加载共用);返回每个插件的结果(失败不中断其他)。
|
||
func (h *Host) LoadDir() map[string]error {
|
||
entries, err := os.ReadDir(h.dir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return map[string]error{}
|
||
}
|
||
return map[string]error{"<dir>": err}
|
||
}
|
||
results := map[string]error{}
|
||
seen := map[string]bool{}
|
||
for _, e := range entries {
|
||
if !e.IsDir() {
|
||
continue
|
||
}
|
||
id := e.Name()
|
||
seen[id] = true
|
||
results[id] = h.loadOne(filepath.Join(h.dir, id))
|
||
}
|
||
// 移除已消失的插件(卸载语义)。
|
||
h.mu.Lock()
|
||
for id := range h.loaded {
|
||
if !seen[id] {
|
||
delete(h.loaded, id)
|
||
h.registry.UnregisterBySource(core.SourcePlugin, id)
|
||
}
|
||
}
|
||
overlay := h.collectIntentsLocked()
|
||
onIntents := h.OnIntents
|
||
h.mu.Unlock()
|
||
if onIntents != nil {
|
||
if err := onIntents(overlay); err != nil {
|
||
results["<intents>"] = err
|
||
}
|
||
}
|
||
return results
|
||
}
|
||
|
||
// loadOne 装载单个插件;任何失败返回错误且不改变既有注册(fail-closed)。
|
||
func (h *Host) loadOne(dir string) error {
|
||
manifestPath := filepath.Join(dir, "plugin.json")
|
||
raw, err := os.ReadFile(manifestPath)
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: %w", filepath.Base(dir), err)
|
||
}
|
||
var m core.PluginManifest
|
||
if err := json.Unmarshal(raw, &m); err != nil {
|
||
return fmt.Errorf("plugins: %s: manifest json: %w", m.ID, err)
|
||
}
|
||
if m.ID == "" || m.Name == "" || m.Version == "" {
|
||
return fmt.Errorf("plugins: %s: manifest missing id/name/version", m.ID)
|
||
}
|
||
if m.APIVersion == "" {
|
||
m.APIVersion = core.PluginAPIVersion
|
||
}
|
||
if m.APIVersion != core.PluginAPIVersion {
|
||
return fmt.Errorf("plugins: %s: apiVersion %s != host %s(兼容窗口外)", m.ID, m.APIVersion, core.PluginAPIVersion)
|
||
}
|
||
|
||
ld := &Loaded{Manifest: m, Dir: dir, Cards: map[string][]core.UINode{}, Prompts: map[string]string{}, Defaults: map[string]any{}}
|
||
|
||
// 同 ID 重载:先注销旧注册(含旧版本工具),再装载新资产。
|
||
h.registry.UnregisterBySource(core.SourcePlugin, m.ID)
|
||
|
||
// tools/*.json:声明式 HTTP 工具(或含 parameters 的描述型工具)。
|
||
for _, name := range m.Provides.Tools {
|
||
var desc core.ToolDesc
|
||
if err := readAsset(dir, "tools", name, &desc); err != nil {
|
||
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
|
||
}
|
||
desc.Source = core.SourcePlugin
|
||
desc.PluginID = m.ID
|
||
exec, err := h.bindToolExecutor(desc)
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: tool %s: %w", m.ID, name, err)
|
||
}
|
||
if err := h.registry.Register(desc, exec); err != nil {
|
||
return fmt.Errorf("plugins: %s: register tool %s: %w", m.ID, name, err)
|
||
}
|
||
}
|
||
|
||
// panels/*.json
|
||
for _, name := range m.Provides.Panels {
|
||
var panel core.PluginPanel
|
||
if err := readAsset(dir, "panels", name, &panel); err != nil {
|
||
return fmt.Errorf("plugins: %s: panel %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Panels = append(ld.Panels, panel)
|
||
}
|
||
|
||
// cards/*.json:{kind, data:[UINode]}
|
||
for _, name := range m.Provides.Cards {
|
||
var card struct {
|
||
Kind string `json:"kind"`
|
||
Data []core.UINode `json:"data"`
|
||
}
|
||
if err := readAsset(dir, "cards", name, &card); err != nil {
|
||
return fmt.Errorf("plugins: %s: card %s: %w", m.ID, name, err)
|
||
}
|
||
if card.Kind == "" {
|
||
return fmt.Errorf("plugins: %s: card %s: missing kind", m.ID, name)
|
||
}
|
||
ld.Cards[card.Kind] = card.Data
|
||
}
|
||
|
||
// workflows/*.json
|
||
for _, name := range m.Provides.Workflows {
|
||
var wf core.WorkflowDef
|
||
if err := readAsset(dir, "workflows", name, &wf); err != nil {
|
||
return fmt.Errorf("plugins: %s: workflow %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Workflows = append(ld.Workflows, wf)
|
||
}
|
||
|
||
// intents/*.json:agent-intent overlay 形态原样透出(目录侧消费,不在此解析)。
|
||
for _, name := range m.Provides.Intents {
|
||
var specs []json.RawMessage
|
||
if err := readAsset(dir, "intents", name, &specs); err != nil {
|
||
return fmt.Errorf("plugins: %s: intents %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Intents = append(ld.Intents, specs...)
|
||
}
|
||
|
||
// prompts/*.md:画像模板(source=plugin,可被用户复制微调)。
|
||
for _, name := range m.Provides.Prompts {
|
||
content, err := os.ReadFile(filepath.Join(dir, "prompts", name+".md"))
|
||
if err != nil {
|
||
return fmt.Errorf("plugins: %s: prompt %s: %w", m.ID, name, err)
|
||
}
|
||
ld.Prompts[name] = string(content)
|
||
}
|
||
|
||
// settings.json:插件设置声明(并入设置中心 schema;默认值进覆盖链底层)。
|
||
if len(m.Provides.Settings) > 0 {
|
||
var descs []core.SettingDesc
|
||
if err := readAsset(dir, "", "settings", &descs); err != nil {
|
||
return fmt.Errorf("plugins: %s: settings: %w", m.ID, err)
|
||
}
|
||
for i := range descs {
|
||
descs[i].Group = "plugin:" + m.ID
|
||
ld.PluginSettings = append(ld.PluginSettings, descs[i])
|
||
}
|
||
}
|
||
|
||
h.mu.Lock()
|
||
h.loaded[m.ID] = ld
|
||
overlay := h.collectIntentsLocked()
|
||
onIntents := h.OnIntents
|
||
h.mu.Unlock()
|
||
_ = h.st.UpsertPlugin(m.ID, dir, m)
|
||
if onIntents != nil {
|
||
if err := onIntents(overlay); err != nil {
|
||
return fmt.Errorf("plugins: %s: intents rejected by host gate (kept previous): %w", m.ID, err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// collectIntentsLocked 汇总全部已装载插件的意图 overlay(调用方持锁)。
|
||
func (h *Host) collectIntentsLocked() []json.RawMessage {
|
||
var out []json.RawMessage
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld.Intents...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// bindToolExecutor:声明式 HTTP 工具绑定执行器;纯描述型(无 http)工具暂不支持(代码插件为远期 sidecar 档)。
|
||
func (h *Host) bindToolExecutor(desc core.ToolDesc) (tools.Executor, error) {
|
||
if desc.HTTP == nil {
|
||
return nil, fmt.Errorf("v1 仅支持声明式 HTTP 工具(缺少 http 段)")
|
||
}
|
||
if desc.HTTP.URL == "" || desc.HTTP.Method == "" {
|
||
return nil, fmt.Errorf("http 工具缺少 url/method")
|
||
}
|
||
lookup := func(key string) string {
|
||
// AuthRef 走用户层设置(如 models.chat.apiKey 或自定义 secret key)。
|
||
eff := h.settings.Effective("")
|
||
return eff.GetString(key, "")
|
||
}
|
||
return tools.HTTPExecutor(*desc.HTTP, lookup), nil
|
||
}
|
||
|
||
func readAsset(dir, sub, name string, v any) error {
|
||
p := filepath.Join(dir, sub, name+".json")
|
||
if sub == "" {
|
||
p = filepath.Join(dir, name+".json")
|
||
}
|
||
raw, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
return json.Unmarshal(raw, v)
|
||
}
|
||
|
||
// ---- 查询接口 ----
|
||
|
||
func (h *Host) List() []*Loaded {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := make([]*Loaded, 0, len(h.loaded))
|
||
for _, ld := range h.loaded {
|
||
out = append(out, ld)
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Manifest.ID < out[j].Manifest.ID })
|
||
return out
|
||
}
|
||
|
||
// SettingsSchema 追加插件设置项(settings schema 汇总用)。
|
||
func (h *Host) SettingsSchema() []core.SettingDesc {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
var out []core.SettingDesc
|
||
for _, ld := range h.List() {
|
||
out = append(out, ld.PluginSettings...)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Cards 返回 kind → 模板(server 演示卡片注入用)。
|
||
func (h *Host) Cards() map[string][]core.UINode {
|
||
h.mu.RLock()
|
||
defer h.mu.RUnlock()
|
||
out := map[string][]core.UINode{}
|
||
for _, ld := range h.loaded {
|
||
for k, v := range ld.Cards {
|
||
out[k] = v
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Unregister 注销插件全部注册项(卸载语义:在途引用由 registry miss 稳定报错)。
|
||
func (h *Host) Unregister(id string) {
|
||
h.mu.Lock()
|
||
delete(h.loaded, id)
|
||
h.mu.Unlock()
|
||
h.registry.UnregisterBySource(core.SourcePlugin, id)
|
||
_ = h.st.RemovePlugin(id)
|
||
}
|
||
|
||
var _ = strings.TrimSpace
|