mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-05 16:57:32 +00:00
## 起因(生产故障)
A2A 分诊助手(驻留子)把 terminal_create 当一次性命令执行器:
terminal_create(command='cd /usr/lib/node_modules/@deepseek-ai && ls -d */ | head -60 && …')
→ read /dev/ptmx: input/output error
→ 再开 term_7、term_8、term_9 …
链路拆开四层:
① cmd 插件 auto_start=false ⇒ 工具表里没有 cmd_run
② 需要 cmd-tools skill 说明"先 plugin_start cmd"
③ 但 skill 索引没进那个 agent 的提示词
④ 因为 skillIndex 只是根 agent 的字段、由 bootstrap 手工接线
④ 才是总根。而它之所以必须手工接线:agent/core 读不到内核的共享数据。
## 环的精确形状(实测,非推测)
kernelboot 因为持有 KernelState 而依赖 19 个业务包:
kernelboot ──▶ agent/core ──▶ …
──▶ plugin
──▶ security
──▶ memory / knowledge / text / coldcontext / …
而 kernelboot.go / scheduler.go / funcmodule.go / syncbus.go / lifecycle.go
**一行业务代码都不需要** —— 依赖全在 kernelstate.go 这一个数据文件里。
后果是四个底层包各自造通道:
core → Agent 字段(每种 agent 构造点都要记得传)
plugin → 12 个 SetXxx
sdk → SDKConfig 传参
security → 包级单例 Global() / RuntimeGlobal() / ConfigCodec()
四条通道是同一个根因的四个表现。
## 本次做的事
### 1. 抽出 internal/kstate(断环)
kernelstate.go / syncbus.go / lifecycle.go 搬出 kernelboot:
internal/kstate 纯数据 + 唯一挂载点(叶子,谁都能 import)
internal/kernelboot 装配引擎,零业务包
挂载点 API:Mount / Get(nil 安全)/ MountForTest(返回 restore,并行测试隔离)。
验证:core 现在能 import kstate(此前 import cycle not allowed)。
### 2. StageHost / Agent 字段存 any
它们是 core 自己的类型(core 是环的一极,这是真环)。按用户裁定的方向:
> 是其他包在kstate挂共享内容啊
存 any,消费方断言回具体类型 —— 与本仓既有的 PluginCatalog /
PluginScheduler / UpdateCapabilities 同型。
★ 其余字段保持**具体类型**,没有一律改 any。判据是"它会不会反向依赖
agent/core":实测 plugin / security / sdk 都不依赖 core,所以它们作为
具体类型不构成环,而类型化字段让"拼错/取错类型"是编译错误(本结构体的
设计原则)。
### 3. 插件声明式系统提示词段
SDK 新增 SystemPromptAPI(见 submodule 提交)。内核侧:
kstate.State.PromptSections *promptsec.Registry ← 唯一事实来源
Agent.renderSystemPrompt() 每轮重建时读它
skillmgr 从"实现 SkillIndexProvider 让内核来问"改为"声明自己的段":
DeclarePromptSection{Section:"skillmgr.skills", Order:100, Volatile:true}
并挂到全部变更点(启动全扫后、移交事件后、load/unload/create/install/
enable/disable 后、Stop 时撤销)。
### 4. 内核自身的提示词段也走同一管理器
按用户裁定「把现在内核所有 systemprompt 的相关注入都接入到这个统一的
systemprompt 管理器上」,把 buildSystemPrompt 里硬拼的十几块
(人格卡/任务提示词/记忆说明/输出规则/工具目录…)改为声明式:
kernelSections() 当轮计算 → 与插件段按 Order 合并渲染。
顺序由 Order 常量决定,改结构变成调常量而不是挪代码块。
## 判据(均经变异验证)
TestCoreCanImportKstate 真编译探针;环重现则红(实测)
TestKstateDoesNotDependOnCore 静态版,指向具体字段
TestKstateIsLeaf kstate 不得反依赖上层
TestRTChildSeesPromptSections 子 agent 能看到插件段
TestResidentSeesPromptSections 生产实际中招的那一类;变异后红(实测)
TestRootAgentSeesPromptSections 确认没改坏根路径
TestNoPromptSectionsMount 未装配时不 panic、不凭空出现段
TestDeclareTwiceRendersOnce ★ 回应"会不会多份":声明 5 次渲染 1 份
TestSectionRemovalTakesEffect Stop 后段消失
TestRuntimeUpdateVisibleToExistingAgent 同一实例下一轮看到新内容
TestSectionOrderIsRespected Order 生效且稳定
## 验证
go build ./... PASS
go vet ./... PASS
gofmt -l internal/ cmd/ pkg/ 干净
go test ./... -count=1 63 包全过,0 FAIL
干净检出(git worktree) build + 4 包测试全绿
## 未做(见 kernelplan.md)
T3 security 三单例收敛(含 Store 复用 Codec 的惰性迁移)
T5 plugin Setter 收敛
T6 防腐判据(部分已随本次落地)
446 lines
15 KiB
Go
446 lines
15 KiB
Go
package skillmgr
|
||
|
||
import (
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/JianFeeeee/HologramAgent/internal/plugin"
|
||
"github.com/JianFeeeee/HologramAgent/internal/sdk"
|
||
)
|
||
|
||
// registerTools 注册 LLM 可调用的 skill 管理工具。
|
||
func (p *Plugin) registerTools() {
|
||
p.registerList()
|
||
p.registerInfo()
|
||
p.registerLoad()
|
||
p.registerUnload()
|
||
p.registerEnable()
|
||
p.registerDisable()
|
||
p.registerCreate()
|
||
p.registerExport()
|
||
p.registerInstall()
|
||
}
|
||
|
||
const tp = "skill_"
|
||
|
||
func (p *Plugin) registerList() {
|
||
p.sdk.RegisterTool(tp+"list", sdk.ToolDef{
|
||
Name: tp + "list",
|
||
Description: "列出所有已加载的原生技能(native skill):名称、版本、启用状态、描述。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
entries := p.snapshot()
|
||
if len(entries) == 0 {
|
||
return "当前没有已加载的原生技能。", nil
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "原生技能 (%d):\n", len(entries))
|
||
for _, e := range entries {
|
||
state := "启用"
|
||
if !e.enabled {
|
||
state = "禁用"
|
||
}
|
||
fmt.Fprintf(&b, " %s v%s [%s] - %s\n",
|
||
e.sk.Name(), e.sk.Version(), state, e.sk.Description())
|
||
}
|
||
return b.String(), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerInfo() {
|
||
p.sdk.RegisterTool(tp+"info", sdk.ToolDef{
|
||
Name: tp + "info",
|
||
Description: "查看指定技能的详细信息与 SKILL.md 全文。执行技能前必读:按文档指示操作。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
e, ok := p.get(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("skill %q not loaded; use skill_list to view available skills", name)
|
||
}
|
||
state := "启用"
|
||
if !e.enabled {
|
||
state = "禁用"
|
||
}
|
||
var b strings.Builder
|
||
fmt.Fprintf(&b, "名称: %s\n版本: %s\n状态: %s\n路径: %s\n描述: %s\n",
|
||
e.sk.Name(), e.sk.Version(), state, e.path, e.sk.Description())
|
||
if tools := e.sk.Tools(); len(tools) > 0 {
|
||
b.WriteString("文档中声明的操作步骤:\n")
|
||
for _, td := range tools {
|
||
fmt.Fprintf(&b, " - %s: %s\n", td.Name, td.Description)
|
||
}
|
||
}
|
||
fmt.Fprintf(&b, "\n--- SKILL.md ---\n%s", e.sk.RawContent())
|
||
return b.String(), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerLoad() {
|
||
p.sdk.RegisterTool(tp+"load", sdk.ToolDef{
|
||
Name: tp + "load",
|
||
Description: "从 skills 目录热加载指定技能(目录名或单 .md 文件名),加载后立即可用。skill_create 填充完内容后调用此工具生效。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "skills 目录下的条目名(不含路径)"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
if strings.ContainsAny(name, "/\\") || strings.HasPrefix(name, ".") {
|
||
return nil, fmt.Errorf("invalid skill entry name: %q", name)
|
||
}
|
||
path := filepath.Join(p.skillsDir, name)
|
||
if _, err := os.Stat(path); err != nil {
|
||
return nil, fmt.Errorf("not found in skills dir: %s", path)
|
||
}
|
||
e, err := p.loadOne(path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
p.publishIndex() // 索引变了:重新声明,否则新 skill 模型看不到
|
||
return fmt.Sprintf("已加载技能 %s v%s", e.sk.Name(), e.sk.Version()), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerUnload() {
|
||
name := tp + "unload"
|
||
p.sdk.RegisterTool(name, sdk.ToolDef{
|
||
Name: name,
|
||
Description: "卸载指定技能(仅从内存移除;磁盘文件保留。需删除文件用参数 delete_files)。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
|
||
"delete_files": map[string]interface{}{"type": "boolean", "description": "是否同时删除磁盘文件(默认 false)"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
delFiles, _ := args["delete_files"].(bool)
|
||
e, ok := p.get(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("skill %q not loaded", name)
|
||
}
|
||
path := e.path
|
||
if !p.removeOne(name) {
|
||
return nil, fmt.Errorf("skill %q not loaded", name)
|
||
}
|
||
p.publishIndex() // 索引变了
|
||
msg := fmt.Sprintf("已卸载技能 %s(内存)", name)
|
||
if delFiles {
|
||
if err := os.RemoveAll(path); err != nil {
|
||
return msg + fmt.Sprintf(",但删除文件失败: %v", err), nil
|
||
}
|
||
msg += ",磁盘文件已删除"
|
||
}
|
||
return msg, nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerEnable() { p.registerSetEnabled(true) }
|
||
func (p *Plugin) registerDisable() {
|
||
p.registerSetEnabled(false)
|
||
}
|
||
|
||
func (p *Plugin) registerSetEnabled(v bool) {
|
||
action := "disable"
|
||
word := "禁用"
|
||
if v {
|
||
action = "enable"
|
||
word = "启用"
|
||
}
|
||
name := tp + action
|
||
desc := word + "指定技能。禁用后 agent 不再使用该技能(保留在列表中标记为禁用)。"
|
||
p.sdk.RegisterTool(name, sdk.ToolDef{
|
||
Name: name,
|
||
Description: desc,
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
skillName, _ := args["name"].(string)
|
||
// ★ 不用 defer:下面要在**持锁之外**调 publishIndex,
|
||
// 而 publishIndex → buildIndex → snapshot 会再取 RLock。
|
||
// sync.RWMutex 不可重入 ⇒ defer 持写锁到函数结束会直接死锁。
|
||
p.mu.Lock()
|
||
e, ok := p.skills[skillName]
|
||
if !ok {
|
||
p.mu.Unlock()
|
||
return nil, fmt.Errorf("skill %q not loaded", skillName)
|
||
}
|
||
e.enabled = v
|
||
e.sk.SetEnabled(v)
|
||
p.mu.Unlock()
|
||
|
||
p.publishIndex() // 启/停改变了索引内容
|
||
return fmt.Sprintf("已%s技能 %s", word, skillName), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerCreate() {
|
||
name := tp + "create"
|
||
p.sdk.RegisterTool(name, sdk.ToolDef{
|
||
Name: name,
|
||
Description: "创建新技能骨架:生成标准 SKILL.md 模板写入 skills/<name>/。创建后在 content 参数填入完整文档内容并再次调用以落盘生效(两步式),或直接传 content 一步完成。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "技能名(小写字母/数字/连字符,如 weather-notify)"},
|
||
"description": map[string]interface{}{"type": "string", "description": "一句话描述技能用途(模板模式的 frontmatter description)"},
|
||
"content": map[string]interface{}{"type": "string", "description": "可选。完整 SKILL.md 内容;提供时直接校验并写入,否则生成骨架模板"},
|
||
"scripts": map[string]interface{}{"type": "boolean", "description": "模板模式是否创建 scripts/ 目录占位(默认 true)"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
description, _ := args["description"].(string)
|
||
content, _ := args["content"].(string)
|
||
withScripts := true
|
||
if ws, ok := args["scripts"].(bool); ok {
|
||
withScripts = ws
|
||
}
|
||
if err := validateSkillName(name); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
dir := filepath.Join(p.skillsDir, name)
|
||
|
||
// 两步式第二步 / 一步式:提供 content 则校验后直接写入
|
||
if strings.TrimSpace(content) != "" {
|
||
if err := plugin.ValidateSKILLContent(content); err != nil {
|
||
return nil, fmt.Errorf("content 校验失败: %w", err)
|
||
}
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(content), 0644); err != nil {
|
||
return nil, err
|
||
}
|
||
e, err := p.loadOne(dir)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
p.publishIndex() // 新建/覆盖技能:索引变了
|
||
return fmt.Sprintf("技能 %s 已写入并加载生效(v%s)。用 skill_info 可查看全文。", e.sk.Name(), e.sk.Version()), nil
|
||
}
|
||
|
||
// 模板模式:生成骨架
|
||
if fileExists(filepath.Join(dir, SkillFileName)) {
|
||
return nil, fmt.Errorf("skill %s already exists at %s; use a different name or edit the file directly", name, dir)
|
||
}
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
return nil, err
|
||
}
|
||
if withScripts {
|
||
os.MkdirAll(filepath.Join(dir, "scripts"), 0755)
|
||
}
|
||
tpl := renderTemplate(name, description)
|
||
if err := os.WriteFile(filepath.Join(dir, SkillFileName), []byte(tpl), 0644); err != nil {
|
||
return nil, err
|
||
}
|
||
return fmt.Sprintf(
|
||
"骨架已生成: %s\n下一步:读取该文件,按实际用途补全正文与操作步骤(## 步骤小节会被解析为技能动作说明),\n然后调用 skill_create 并传入完整 content 覆盖写入,最后自动加载生效。",
|
||
filepath.Join(dir, SkillFileName)), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerExport() {
|
||
p.sdk.RegisterTool(tp+"export", sdk.ToolDef{
|
||
Name: tp + "export",
|
||
Description: "导出指定技能为 .skm 分发包(tar.gz 格式),输出到 output 路径或默认 data/exports/ 下。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"name": map[string]interface{}{"type": "string", "description": "技能名称"},
|
||
"output": map[string]interface{}{"type": "string", "description": "输出 .skm 文件完整路径(可选,默认 data/exports/<name>.skm)"},
|
||
},
|
||
"required": []string{"name"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
name, _ := args["name"].(string)
|
||
output, _ := args["output"].(string)
|
||
e, ok := p.get(name)
|
||
if !ok {
|
||
return nil, fmt.Errorf("skill %q not loaded; use skill_list first", name)
|
||
}
|
||
outPath := output
|
||
if outPath == "" {
|
||
exportsDir := filepath.Join(p.skillsDir, "..", "exports")
|
||
os.MkdirAll(exportsDir, 0755)
|
||
outPath = filepath.Join(exportsDir, name+PackExt)
|
||
}
|
||
n, err := packSkill(e.path, outPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return fmt.Sprintf("已导出 %d 个文件到 %s", n, outPath), nil
|
||
})
|
||
}
|
||
|
||
func (p *Plugin) registerInstall() {
|
||
p.sdk.RegisterTool(tp+"install", sdk.ToolDef{
|
||
Name: tp + "install",
|
||
Description: "安装技能包:支持 .skm 包路径或 local:<skills目录路径> 本地目录。同名技能已存在时传 overwrite=true 原地覆盖(保留无持久配置,直接替换文件)。安装后立即加载生效。",
|
||
Parameters: map[string]interface{}{
|
||
"type": "object",
|
||
"properties": map[string]interface{}{
|
||
"source": map[string]interface{}{"type": "string", "description": "安装来源:<path>.skm 或 local:<dir>"},
|
||
"overwrite": map[string]interface{}{"type": "boolean", "description": "同名技能存在时覆盖更新(默认 false)"},
|
||
},
|
||
"required": []string{"source"},
|
||
},
|
||
}, func(args map[string]interface{}) (interface{}, error) {
|
||
source, _ := args["source"].(string)
|
||
overwrite, _ := args["overwrite"].(bool)
|
||
source = strings.TrimSpace(source)
|
||
switch {
|
||
case strings.HasPrefix(source, "local:"):
|
||
dir := strings.TrimPrefix(source, "local:")
|
||
info, err := os.Stat(dir)
|
||
if err != nil || !info.IsDir() {
|
||
return nil, fmt.Errorf("local dir not found: %s", dir)
|
||
}
|
||
dstName := filepath.Base(dir)
|
||
if err := validateSkillName(dstName); err != nil {
|
||
return nil, err
|
||
}
|
||
dst := filepath.Join(p.skillsDir, dstName)
|
||
if err := p.replaceSkillDir(dst, overwrite); err != nil {
|
||
return nil, err
|
||
}
|
||
if err := copyDir(dir, dst); err != nil {
|
||
return nil, fmt.Errorf("copy failed: %w", err)
|
||
}
|
||
e, err := p.loadOne(dst)
|
||
if err != nil {
|
||
os.RemoveAll(dst)
|
||
return nil, fmt.Errorf("installed but load failed: %w", err)
|
||
}
|
||
p.publishIndex() // 安装:索引变了
|
||
return fmt.Sprintf("已从本地目录安装技能 %s v%s", e.sk.Name(), e.sk.Version()), nil
|
||
|
||
case strings.HasSuffix(strings.ToLower(source), PackExt):
|
||
if _, err := os.Stat(source); err != nil {
|
||
return nil, fmt.Errorf("pack not found: %s", source)
|
||
}
|
||
dstName := strings.TrimSuffix(filepath.Base(source), PackExt)
|
||
if err := validateSkillName(dstName); err != nil {
|
||
return nil, err
|
||
}
|
||
dst := filepath.Join(p.skillsDir, dstName)
|
||
if err := p.replaceSkillDir(dst, overwrite); err != nil {
|
||
return nil, err
|
||
}
|
||
n, err := unpackSkill(source, dst)
|
||
if err != nil {
|
||
os.RemoveAll(dst)
|
||
return nil, fmt.Errorf("unpack failed: %w", err)
|
||
}
|
||
e, err := p.loadOne(dst)
|
||
if err != nil {
|
||
os.RemoveAll(dst)
|
||
return nil, fmt.Errorf("unpacked %d files but load failed: %w", n, err)
|
||
}
|
||
p.publishIndex() // 安装:索引变了
|
||
return fmt.Sprintf("已安装 %d 个文件,技能 %s v%s 生效", n, e.sk.Name(), e.sk.Version()), nil
|
||
|
||
default:
|
||
return nil, fmt.Errorf("unsupported source: %q (use <path>.skm or local:<dir>)", source)
|
||
}
|
||
})
|
||
}
|
||
|
||
// replaceSkillDir 安装前的同名目录处理:不存在则放行;存在且 overwrite=true
|
||
// 则先卸载旧实例并删除旧目录(skill 无持久配置,直接替换);否则报错。
|
||
func (p *Plugin) replaceSkillDir(dst string, overwrite bool) error {
|
||
if _, err := os.Stat(dst); err != nil {
|
||
return nil // 不存在,直接装
|
||
}
|
||
if !overwrite {
|
||
return fmt.Errorf("skill dir already exists: %s (传 overwrite=true 覆盖更新)", dst)
|
||
}
|
||
name := filepath.Base(dst)
|
||
p.removeOne(name) // 从注册表移除旧实例
|
||
if err := os.RemoveAll(dst); err != nil {
|
||
return fmt.Errorf("remove old skill dir: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// validateSkillName 校验技能名:小写字母/数字/连字符,1-64 字符。
|
||
func validateSkillName(name string) error {
|
||
if name == "" || len(name) > 64 {
|
||
return fmt.Errorf("invalid skill name length")
|
||
}
|
||
for _, r := range name {
|
||
ok := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' || r == '.'
|
||
if !ok {
|
||
return fmt.Errorf("invalid character %q in skill name %q (allow lowercase/digits/-/_/.)", r, name)
|
||
}
|
||
}
|
||
if strings.HasPrefix(name, ".") {
|
||
return fmt.Errorf("skill name cannot start with dot")
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// renderTemplate 生成 SKILL.md 骨架。
|
||
func renderTemplate(name, description string) string {
|
||
if description == "" {
|
||
description = "(TODO: 一句话描述本技能的用途)"
|
||
}
|
||
return fmt.Sprintf(`---
|
||
name: %s
|
||
version: 0.1.0
|
||
author: homeagent
|
||
---
|
||
|
||
# %s
|
||
|
||
%s
|
||
|
||
## 使用时机
|
||
|
||
(TODO: 描述什么场景下 agent 应当使用本技能)
|
||
|
||
## 操作步骤
|
||
|
||
### step-1
|
||
|
||
(TODO: 第一步做什么。首行非标题文本会作为该步骤的描述)
|
||
|
||
- param1: 参数1说明
|
||
- param2: 参数2说明
|
||
|
||
执行示例:
|
||
|
||
`+"```"+`bash
|
||
echo "hello from %s"
|
||
`+"```"+`
|
||
|
||
## 注意事项
|
||
|
||
- (TODO: 安全提示、失败重试策略等)
|
||
`, name, name, description, name)
|
||
}
|