Files
Agent-System-Backend/internal/plugins/host.go
T
weichen 5c52c71f9c Add cmdbus: registry-driven command dispatch with audit and inline confirm
Why: T3 of the interaction foundation - unify button/card/slash/chat entries onto one command bus (D27) composing the Agent-Command library (D30), with danger=high commands gated behind inline confirm cards and every dispatch leaving the same audit envelope.
Evidence: go test green - cmdbus suite (four-entrance audit symmetry, high-danger confirm lifecycle incl. cancel/expiry/handler-reject, audit-unavailable yields unknown, plugin spec fail-closed, schema scalar validation) and server e2e (danger command via POST /api/commands -> confirm card -> card action confirm -> committed receipt -> audit query ordered accepted->committed); legacy /help,/new,/mock,/model,/profile contract preserved through the bus; store migration v7 command_audit (both dialects) with roundtrip test updated.
Impact: new internal/cmdbus (L1 package per D33), server routes POST /api/commands/{id} + GET /api/commands/audit + registry-driven GET /api/commands, postMessage chat-entrance dispatch when the intent gate matches a registered command, plugins host OnCommands overlay, module cards policy/modules/{cmdbus,cardsvc}.md; pending confirm state is process-local (documented limitation).
Refs: docs/proposals/2026-09-30-ui-inbox-command-workspace.md T3; task card feature/command-bus-r1
2026-09-30 07:51:31 +00:00

325 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 形态(目录侧消费)
Commands []json.RawMessage // Agent-Command spec overlay(cmdbus 注册)
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
// OnCommands 在装载集合变化后回调全部命令 spec overlay(宿主接命令总线;nil = 不接)。
OnCommands 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()
cmdOverlay := h.collectCommandsLocked()
onIntents, onCommands := h.OnIntents, h.OnCommands
h.mu.Unlock()
if onCommands != nil {
if err := onCommands(cmdOverlay); err != nil {
results["<commands>"] = err
}
}
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...)
}
// commands/*.json:Agent-Command spec overlay 形态透出(cmdbuf 注册;不在此校验语义)。
for _, name := range m.Provides.Commands {
var specs []json.RawMessage
if err := readAsset(dir, "commands", name, &specs); err != nil {
return fmt.Errorf("plugins: %s: commands %s: %w", m.ID, name, err)
}
ld.Commands = append(ld.Commands, 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()
cmdOverlay := h.collectCommandsLocked()
onIntents, onCommands := h.OnIntents, h.OnCommands
h.mu.Unlock()
_ = h.st.UpsertPlugin(m.ID, dir, m)
if onCommands != nil {
if err := onCommands(cmdOverlay); err != nil {
return fmt.Errorf("plugins: %s: commands rejected by command bus (kept previous): %w", m.ID, err)
}
}
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
}
// collectCommandsLocked 汇总全部已装载插件的命令 overlay(调用方持锁)。
func (h *Host) collectCommandsLocked() []json.RawMessage {
var out []json.RawMessage
for _, ld := range h.loaded {
out = append(out, ld.Commands...)
}
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(store.OwnerDefault, "")
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