// Package plugin 实现插件系统. // // 插件系统允许通过外部目录扩展 Flyto Agent Engine 的功能. // 每个插件是一个包含 plugin.json 清单的目录,可以提供: // - 技能(markdown 文件,注入到系统提示词) // - Hook 定义(扩展 hook 系统) // - MCP 服务器配置 // // 插件搜索路径: // - ~/.flyto/plugins/ - 用户级插件 // - /.flyto/plugins/ - 项目级插件 // // 插件加载顺序:用户级先加载,项目级后加载,同名时后者覆盖前者. // // 升华改进(ELEVATED): 相较早期方案只支持 CLI 安装的插件,我们增加了: // 1. RegisterBuiltin - SDK 模式下直接在代码中注册插件,无需文件系统 // 2. LoadAll 返回 LoadResult - 区分成功/降级/错误,而非遇到一个错误就中断 // 3. Source 字段 - 精确追踪插件来源,支持依赖跨源校验 // // 原方案 - 否决原因:第一个插件加载失败会屏蔽其他所有插件的状态. package plugin import ( "fmt" "sync" "git.flytoex.net/yuanwei/flyto-agent/pkg/execenv" "git.flytoex.net/yuanwei/flyto-agent/pkg/tools" ) // Plugin 是一个已加载的插件实例. type Plugin struct { // Name 插件名称(唯一标识符) Name string // Description 插件描述 Description string // Version 插件版本 Version string // Path 插件目录的绝对路径(内置插件为空字符串) Path string // Source 插件的来源级别(内置/用户级/项目级) // 影响依赖解析中的跨来源规则. Source Source // Manifest 插件清单(plugin.json 的解析结果);内置插件为 nil Manifest *Manifest // Enabled 插件是否启用 Enabled bool // Skills 插件提供的技能列表 Skills []*Skill // Hooks 插件提供的 hook 定义,key 是 hook 类型 Hooks map[string][]HookDef // MCPServers 插件提供的 MCP 服务器配置 MCPServers []MCPServerDef // Tools 插件通过 plugin.json 的 tools 字段声明式注册的 shell tool 运行时实例. // // 区别于 MCPServers 字段: MCPServers 是配置 (启动外部 MCP server 进程), // Tools 是已经实例化的 tools.Tool, 可以直接注册到 tools.Registry. // 详见 plugin_tool.go 的 "为什么同时需要 MCP server 和 declarative tool // 两条路径" 段落. // // 消费层 (Host 或 engine) 通过 Host.GetAllTools() 收集所有启用插件的 tools, // 并调用 tools.Registry.Register() 逐个注册. Tools []tools.Tool } // BuiltinDef 是内置插件的注册信息. // // 升华改进(ELEVATED): SDK 嵌入场景下,消费层可以将内置能力(如仓储查询工具) // 打包为内置插件注册,享受与文件系统插件相同的 enable/disable/skill 聚合接口, // 无需在代码和插件系统之间维护两套资源管理逻辑. // 原方案 <直接向 Host 注入 Skills 列表> - 否决原因:绕过了插件生命周期管理(enable/disable), // 且与文件系统插件的统一查询接口不兼容. type BuiltinDef struct { // Name 内置插件名称(必须唯一,不能与已注册插件冲突) Name string // Description 描述 Description string // Version 版本,可留空(默认 "0.0.0") Version string // Skills 内置技能列表 Skills []*Skill // Hooks 内置 hook 定义 Hooks map[string][]HookDef // MCPServers 内置 MCP 服务器配置 MCPServers []MCPServerDef } // Host 是插件宿主,管理所有插件的加载,启用/禁用和资源访问. // 是消费层与插件系统交互的唯一入口. // // 线程安全:所有操作通过 sync.RWMutex 保护. type Host struct { mu sync.RWMutex plugins map[string]*Plugin // name -> Plugin // searchPaths 是插件搜索路径列表 searchPaths []string // configStore 插件运行时配置存储(12.6 Plugin Config Schema). // // 升华改进(ELEVATED): 配置存储与 Host 绑定,每个 Host 实例独立-- // SaaS 多租户场景下不同租户的插件配置互不干扰. // 替代方案:全局 map(多租户场景下配置互相污染). configStore *pluginConfigStore // executor 是子进程启动抽象, 透传给 pluginShellTool 让其 Execute 走 // 统一 Spec 抽象而非直写 exec.Cmd. M1 commit 7b 引入, 方案 β 严格 DI: // NewHost 时必填, nil 直接 panic. 本地模式传 execenv.DefaultExecutor{}, // 云端 SaaS 传 platform/common/internal/sandbox.Backend. // // 为什么放 Host 而不是每个 plugin 方法参数传: LoadFromDir / LoadAll // 是公开 API, 给每个方法加 executor 参数会让 20+ caller 全部变脸; // Host 持有一次, 后续所有 loader 路径共享同一实例, 心智成本低. executor execenv.Executor } // NewHost 创建一个新的插件宿主. // // executor 必填, nil panic. 方案 β 严格 DI 要求所有子进程启动点都走 // 统一 execenv.Executor 抽象, 禁止静默降级. 本地 CLI 传 // execenv.DefaultExecutor{}, 云端 platform 层传 sandbox backend. func NewHost(executor execenv.Executor) *Host { if executor == nil { panic("plugin.NewHost: executor must not be nil (方案 β 严格 DI, 本地模式传 execenv.DefaultExecutor{})") } return &Host{ plugins: make(map[string]*Plugin), configStore: newPluginConfigStore(), executor: executor, } } // RegisterBuiltin 在内存中注册一个内置插件. // 内置插件不来自文件系统,由 SDK 消费者在代码中直接注册. // // 若同名内置插件已存在则覆盖(允许重复注册,方便测试). // 若与已加载的文件系统插件同名,返回 ErrBuiltinConflict 错误. func (h *Host) RegisterBuiltin(def BuiltinDef) error { h.mu.Lock() defer h.mu.Unlock() // 冲突检测:只检查已有的非内置插件(文件系统来源) if existing, ok := h.plugins[def.Name]; ok && existing.Source != SourceBuiltin { return PluginError{ Code: ErrBuiltinConflict, PluginName: def.Name, Message: fmt.Sprintf( "builtin plugin %q conflicts with already-loaded %s-level plugin at %q", def.Name, sourceLabel(existing.Source), existing.Path, ), } } version := def.Version if version == "" { version = "0.0.0" } hooks := def.Hooks if hooks == nil { hooks = make(map[string][]HookDef) } h.plugins[def.Name] = &Plugin{ Name: def.Name, Description: def.Description, Version: version, Source: SourceBuiltin, Enabled: true, Skills: def.Skills, Hooks: hooks, MCPServers: def.MCPServers, } return nil } // LoadAll 发现并加载所有搜索路径下的插件. // // 加载流程: // 1. 按路径顺序发现所有插件目录(后发现的同名插件覆盖先发现的) // 2. 完整加载每个插件(清单→技能→hooks→MCP) // 3. 依赖解析:ResolveClosure 计算传递闭包 // 4. VerifyAndDemote:依赖缺失/错误的插件降级禁用 // 5. 更新 Host 内部状态 // // 返回 LoadResult,调用方可以按需处理 Errors/Warnings, // 不会因为单个插件问题中断其他插件的加载. func (h *Host) LoadAll(searchPaths []string) LoadResult { h.mu.Lock() defer h.mu.Unlock() h.searchPaths = searchPaths var result LoadResult // 第一步:发现并完整加载每个插件 discovered := discoverWithSource(searchPaths) registry := make(map[string]*Plugin, len(discovered)+len(h.plugins)) // 把已注册的内置插件也放入注册表(依赖解析需要) for name, p := range h.plugins { if p.Source == SourceBuiltin { registry[name] = p } } // 加载发现的插件,记录错误 for _, stub := range discovered { loaded, errs := loadPluginWithErrors(stub, h.executor) if len(errs) > 0 { // 只要有致命错误就不放入注册表 result.Errors = append(result.Errors, errs...) continue } registry[loaded.Name] = loaded } // 第二步:收集所有需要启用的插件名称(内置 + 新加载的) allNames := make([]string, 0, len(registry)) for name := range registry { allNames = append(allNames, name) } // 第三步:依赖解析(DFS 传递闭包) ordered, depErrs := ResolveClosure(allNames, registry) result.Errors = append(result.Errors, depErrs...) // 第四步:VerifyAndDemote - 传播依赖禁用 enabled, disabled, warnings := VerifyAndDemote(ordered, nil, result.Errors) result.Disabled = disabled result.Warnings = append(result.Warnings, warnings...) // 第五步:更新 Host 状态 // 保留内置插件,用新加载结果替换文件系统插件 for name, p := range h.plugins { if p.Source != SourceBuiltin { delete(h.plugins, name) } } for _, p := range enabled { p.Enabled = true h.plugins[p.Name] = p } for _, p := range disabled { p.Enabled = false h.plugins[p.Name] = p } result.Enabled = enabled return result } // Load registers a single plugin definition as a metadata-only placeholder // in the host registry. It does NOT parse skills / hooks / tools / // MCPServers -- those come from LoadFromDir (which reads plugin.json on // disk) or RegisterBuiltin (in-binary). Use this when the caller wants // the host to know a plugin exists but has no plugin.json at hand, e.g. // SDK tests or engine.Config.Plugins wiring. // // The created Plugin is marked Enabled=true and pinned to SourceUser so // RemoveByScope(SourceUser) sweeps it alongside real disk-loaded user // plugins. The Source enum value is a coarse tag; finer "came from // Config.Plugins" attribution is the caller's responsibility if needed. // // Load 把一个 plugin 定义作为 metadata-only 占位登记到 host registry. // 不解析 skills / hooks / tools / MCPServers -- 那些走 LoadFromDir // (读磁盘上的 plugin.json) 或 RegisterBuiltin (内嵌二进制). 调用方想 // 让 host 知道某 plugin 存在但手头没 plugin.json 时用这条, e.g. // SDK 测试或 engine.Config.Plugins wiring. // // 创建的 Plugin 标记 Enabled=true, Source 钉为 SourceUser, 这样 // RemoveByScope(SourceUser) 扫的时候能跟真正从磁盘加载的 user plugin // 一起清. Source 枚举是粗分类 tag; 如果需要更细的 "来自 Config.Plugins" // 归因, 由调用方自己记. func (h *Host) Load(def Definition) error { h.mu.Lock() defer h.mu.Unlock() p := &Plugin{ Name: def.Name, Description: def.Description, Path: def.Source, Source: SourceUser, // 旧接口默认用户级 Enabled: true, Hooks: make(map[string][]HookDef), } h.plugins[p.Name] = p return nil } // LoadFromDir 从指定目录加载并注册一个插件. func (h *Host) LoadFromDir(dir string) error { loaded, errs := loadPluginWithErrors(pluginStub{Path: dir, Source: SourceUser}, h.executor) if len(errs) > 0 { return errs[0] } h.mu.Lock() defer h.mu.Unlock() h.plugins[loaded.Name] = loaded return nil } // Enable 启用指定名称的插件. func (h *Host) Enable(name string) error { h.mu.Lock() defer h.mu.Unlock() p, ok := h.plugins[name] if !ok { return fmt.Errorf("plugin %q not found", name) } p.Enabled = true return nil } // Disable 禁用指定名称的插件. func (h *Host) Disable(name string) error { h.mu.Lock() defer h.mu.Unlock() p, ok := h.plugins[name] if !ok { return fmt.Errorf("plugin %q not found", name) } p.Enabled = false return nil } // Get 获取指定名称的插件. func (h *Host) Get(name string) (*Plugin, bool) { h.mu.RLock() defer h.mu.RUnlock() p, ok := h.plugins[name] return p, ok } // List 列出所有已加载的插件. func (h *Host) List() []*Plugin { h.mu.RLock() defer h.mu.RUnlock() result := make([]*Plugin, 0, len(h.plugins)) for _, p := range h.plugins { result = append(result, p) } return result } // ListEnabled 列出所有启用的插件. func (h *Host) ListEnabled() []*Plugin { h.mu.RLock() defer h.mu.RUnlock() var result []*Plugin for _, p := range h.plugins { if p.Enabled { result = append(result, p) } } return result } // GetAllSkills 收集所有启用插件提供的技能. func (h *Host) GetAllSkills() []*Skill { h.mu.RLock() defer h.mu.RUnlock() var allSkills []*Skill for _, p := range h.plugins { if !p.Enabled { continue } allSkills = append(allSkills, p.Skills...) } return allSkills } // GetAllHooks 收集所有启用插件提供的 hook 定义. func (h *Host) GetAllHooks() map[string][]HookDef { h.mu.RLock() defer h.mu.RUnlock() allHooks := make(map[string][]HookDef) for _, p := range h.plugins { if !p.Enabled { continue } for hookType, hooks := range p.Hooks { allHooks[hookType] = append(allHooks[hookType], hooks...) } } return allHooks } // GetAllMCPServers 收集所有启用插件提供的 MCP 服务器配置. func (h *Host) GetAllMCPServers() []MCPServerDef { h.mu.RLock() defer h.mu.RUnlock() var allServers []MCPServerDef for _, p := range h.plugins { if !p.Enabled { continue } allServers = append(allServers, p.MCPServers...) } return allServers } // GetAllTools 收集所有启用插件通过 plugin.json 的 tools 字段声明式注册的 tool 实例. // // 与 GetAllMCPServers 的区别: GetAllMCPServers 返回的是 **配置** (MCPServerDef), // 调用方还要自己启动 subprocess 并走 MCP 协议发现 tool. GetAllTools 返回的是 // **已实例化**的 tools.Tool, 调用方可以直接 `tools.Registry.Register(t)`. // // 使用场景: 消费层 (Host/engine 启动时) 调用此方法收集所有 declarative tool, // 注册到全局 Registry, 让 agent 可以直接调用. 典型代码: // // for _, t := range host.GetAllTools() { // if err := registry.Register(t); err != nil { // log.Printf("register tool %q: %v", t.Name(), err) // } // } // // 精妙之处 (CLEVER): 本方法返回类型是 []tools.Tool 而非 []*pluginShellTool, // 因为未来 plugin 可能通过别的机制贡献 tool (WASM / Go static binary / 内置 // built-in tool 提供器), 接口类型能无缝包含所有实现. func (h *Host) GetAllTools() []tools.Tool { h.mu.RLock() defer h.mu.RUnlock() var allTools []tools.Tool for _, p := range h.plugins { if !p.Enabled { continue } allTools = append(allTools, p.Tools...) } return allTools } // SetPluginConfig 设置指定插件的配置 map,并按 schema 验证配置有效性. // // 调用时机:消费层在 LoadAll/LoadFromDir 之后,Run 之前调用-- // 提供插件运行所需的配置(API Key,endpoint 等). // // 若插件未加载(name 不在注册表中),仍接受配置设置(允许预配置后再加载). // 若插件有 ConfigSchema,ValidatePluginConfig 验证 Required 字段和类型约束; // 验证失败返回 PluginError{Code: ErrConfigValidation}. // // 精妙之处(CLEVER): 先验证再存储--无效配置不会写入存储, // 确保 GetPluginConfig 返回的配置一定通过了 schema 校验. // 替代方案:先存储后验证(消费层可能用到未验证的配置,带来不一致风险). func (h *Host) SetPluginConfig(name string, config map[string]string) error { h.mu.RLock() p, exists := h.plugins[name] h.mu.RUnlock() // 若插件已加载且有 schema,验证配置 if exists && p.Manifest != nil && len(p.Manifest.ConfigSchema) > 0 { if err := ValidatePluginConfig(name, p.Manifest.ConfigSchema, config); err != nil { return err } } h.configStore.Set(name, config) return nil } // GetPluginConfig 获取指定插件的完整配置 map. // // 返回值是配置的副本(修改不影响内部存储). // 若未设置配置,返回空 map(非 nil). // // 升华改进(ELEVATED): 插件通过此方法读取自己的配置,隔离命名空间-- // 不同插件的 "api_key" 字段各自独立,无需前缀拼接. // 替代方案:<直接暴露 pluginConfigStore> // - 否决:绕过了 Host 的封装,外部可能破坏内部一致性. func (h *Host) GetPluginConfig(name string) map[string]string { return h.configStore.Get(name) } // GetPluginConfigField 获取指定插件的单个配置字段值. // 若字段不存在,返回空字符串和 false. func (h *Host) GetPluginConfigField(name, key string) (string, bool) { return h.configStore.GetField(name, key) } // Count 返回已加载的插件数量. func (h *Host) Count() int { h.mu.RLock() defer h.mu.RUnlock() return len(h.plugins) } // Close 关闭所有插件,释放资源. func (h *Host) Close() { h.mu.Lock() defer h.mu.Unlock() h.plugins = make(map[string]*Plugin) } // Definition 是插件定义(旧接口兼容). type Definition struct { Name string `json:"name"` Description string `json:"description"` Source string `json:"source"` // 插件标识符 } // sourceLabel 返回来源的可读名称,用于错误消息. func sourceLabel(s Source) string { switch s { case SourceBuiltin: return "builtin" case SourceUser: return "user" case SourceProject: return "project" default: return "unknown" } }