766 lines
27 KiB
Markdown
766 lines
27 KiB
Markdown
# Skill 系统演进设计
|
||
|
||
> 日期:2026-04-27
|
||
> 状态:Draft
|
||
> 范围:P0 密钥管理 · P1 运行时执行器 · P2 标准兼容 · P3 碰撞检测与诊断
|
||
|
||
## 1. 背景与目标
|
||
|
||
当前 Neta 的 Skill 系统以 SKILL.md 为载体,支持 GitHub/ZIP/本地安装,具备条件激活和附属文件读取能力。但存在以下架构缺口:
|
||
|
||
- Skill 的 API Key / 密钥无归属,混在主系统环境变量或 prompt 硬编码中
|
||
- Skill 自有代码(Python/Node/.NET 脚本)的执行完全依赖 Agent 通过 bash 间接调用,无标准化执行器
|
||
- 与 Agent Skills 社区标准(agentskills.io)不完全兼容,社区 skill 安装后可能缺少字段映射
|
||
- 多来源安装缺少碰撞检测和诊断,同名冲突无预警
|
||
- Agent 读完 SKILL.md 后经常不读 references/ 子文档,导致执行质量下降
|
||
|
||
### 参考项目
|
||
|
||
- **pi-mono-main**:纯 prompt skill 系统,多层级发现、碰撞检测、诊断系统、名称规范验证
|
||
- **skills-main**(MiniMax Skills):18 个生产级 skill,涵盖 PDF/DOCX/XLSX/PPTX 文档处理、多模态 API 调用,混合运行时(Python + Node + .NET + bash),系统级依赖(ffmpeg/LibreOffice)
|
||
|
||
## 2. Skill 分类体系
|
||
|
||
### 2.1 三种 Skill 类型
|
||
|
||
| 类型 | 判断条件 | Agent 交互方式 | 典型场景 |
|
||
|------|----------|---------------|---------|
|
||
| **prompt** | 只有 SKILL.md,无 skill.config.yaml | `read_skill` → 遵循指令 | llm-wiki、社区标准 skill |
|
||
| **compute-entry** | config 有 `entrypoint` | `execute_skill` 工具 | OCR 接口封装、单一 API 调用 |
|
||
| **compute-toolkit** | config 有 `runtime` 但无 `entrypoint` | `read_skill` → bash 执行脚本 | minimax-pdf、minimax-xlsx |
|
||
|
||
### 2.2 目录结构
|
||
|
||
**prompt skill(标准兼容):**
|
||
|
||
```
|
||
skills/llm-wiki/
|
||
├── SKILL.md
|
||
└── references/
|
||
```
|
||
|
||
**compute-entry skill:**
|
||
|
||
```
|
||
skills/ocr-reader/
|
||
├── SKILL.md
|
||
├── skill.config.yaml
|
||
├── src/ocr.py
|
||
├── requirements.txt
|
||
└── .venv/ ← 安装时自动创建
|
||
```
|
||
|
||
**compute-toolkit skill:**
|
||
|
||
```
|
||
skills/minimax-pdf/
|
||
├── SKILL.md
|
||
├── skill.config.yaml
|
||
├── scripts/
|
||
│ ├── palette.py
|
||
│ ├── render_cover.js
|
||
│ └── make.sh
|
||
├── design/
|
||
│ └── design.md
|
||
├── references/
|
||
│ └── *.md
|
||
├── requirements.txt
|
||
└── .venv/
|
||
```
|
||
|
||
### 2.3 skill.config.yaml 完整 Schema
|
||
|
||
```yaml
|
||
# --- 运行时声明 ---
|
||
runtime: python | node | bash | dotnet # 主运行时
|
||
entrypoint: src/ocr.py # 可选,有则为 compute-entry,无则为 compute-toolkit
|
||
timeout: 30000 # 执行超时 ms,默认 30s
|
||
|
||
# --- 环境变量声明 ---
|
||
env:
|
||
- name: OCR_API_KEY
|
||
required: true
|
||
description: "OCR 服务 API Key"
|
||
- name: OCR_ENDPOINT
|
||
required: false
|
||
default: "https://api.ocr.com/v1"
|
||
|
||
# --- 依赖声明 ---
|
||
dependencies:
|
||
system: # 系统级依赖
|
||
- name: ffmpeg
|
||
check: "ffmpeg -version" # 检测命令
|
||
- name: libreoffice
|
||
check: "soffice --version"
|
||
python:
|
||
source: requirements.txt # 或 inline: ["httpx>=0.27", "pillow"]
|
||
node:
|
||
packages: ["pptxgenjs"] # npm install 到 skill 目录
|
||
dotnet:
|
||
project: scripts/dotnet/MyProject.Cli # dotnet restore 路径
|
||
|
||
# --- 安装钩子 ---
|
||
setup:
|
||
posix: scripts/setup.sh # macOS/Linux
|
||
win32: scripts/setup.ps1 # Windows
|
||
|
||
# --- compute-entry 专用:接口声明 ---
|
||
interface:
|
||
input:
|
||
image_path: { type: string, required: true, description: "图片路径" }
|
||
language: { type: string, default: "auto" }
|
||
output:
|
||
text: { type: string }
|
||
confidence: { type: number }
|
||
|
||
# --- references 加载策略 ---
|
||
references:
|
||
required: # 执行前必须读取
|
||
- references/create.md
|
||
- references/format.md
|
||
optional: # 按需读取
|
||
- references/tracing.md
|
||
routes: # 任务路由 → 文档映射
|
||
- match: ["create", "generate", "new"]
|
||
required_refs: ["references/create.md", "references/format.md"]
|
||
- match: ["edit", "modify", "fill"]
|
||
required_refs: ["references/edit.md"]
|
||
```
|
||
|
||
## 3. P0:Skill-Scoped 密钥管理
|
||
|
||
### 3.1 数据层
|
||
|
||
`netaclaw_skill` 表新增两个字段:
|
||
|
||
```typescript
|
||
// entity/skill.ts
|
||
@Column({ type: 'text', comment: 'AES-256-GCM 加密的 secrets JSON', nullable: true })
|
||
secrets: string;
|
||
|
||
@Column({ type: 'json', comment: 'env 声明 schema', nullable: true })
|
||
envSchema: Array<{ name: string; required: boolean; description?: string; default?: string }>;
|
||
```
|
||
|
||
### 3.2 新增服务:SkillSecretService
|
||
|
||
```typescript
|
||
// service/skill_secret.ts
|
||
@Provide()
|
||
@Scope(ScopeEnum.Singleton)
|
||
export class SkillSecretService {
|
||
// AES-256-GCM 加密,密钥来自 process.env.SKILL_SECRET_KEY || process.env.APP_SECRET
|
||
encrypt(plainObj: Record<string, string>): string;
|
||
decrypt(cipherText: string): Record<string, string>;
|
||
|
||
// 合并 DB secrets + envSchema defaults,返回完整 env map
|
||
async resolveEnv(skillName: string): Promise<Record<string, string>>;
|
||
|
||
// 保存 secrets(加密后写入 DB)
|
||
async saveSecrets(skillName: string, secrets: Record<string, string>): Promise<void>;
|
||
|
||
// 获取已配置的 key 列表(不返回明文)
|
||
async getConfiguredKeys(skillName: string): Promise<Array<{ name: string; hasValue: boolean }>>;
|
||
}
|
||
```
|
||
|
||
### 3.3 API 变更
|
||
|
||
Admin controller 新增:
|
||
|
||
```
|
||
GET /admin/netaclaw/skill/envSchema?name=ocr-reader
|
||
→ 返回 envSchema 声明 + 每个 key 的 hasValue 状态
|
||
|
||
POST /admin/netaclaw/skill/secrets
|
||
body: { name: "ocr-reader", secrets: { "OCR_API_KEY": "sk-xxx" } }
|
||
→ 加密后存入 DB,不返回明文
|
||
```
|
||
|
||
### 3.4 前端变更
|
||
|
||
`skill-detail.vue` 抽屉新增"配置"区域:
|
||
|
||
- 读取 envSchema 展示每个变量的 name、description、required 标记
|
||
- 已配置的显示 `[********]` + 修改按钮
|
||
- 未配置且 required 的显示红色提示
|
||
- 保存时调用 `/secrets` 接口
|
||
|
||
### 3.5 运行时 env 注入
|
||
|
||
**compute-entry 模式:** `SkillExecutorService.execute()` 调用 `resolveEnv(skillName)` 注入到子进程 env。
|
||
|
||
**compute-toolkit 模式:** bash 工具执行时,检查脚本路径是否落在某个 skill 目录下。如果是,自动从 `SkillSecretService.resolveEnv()` 获取对应 env 注入到子进程。判断逻辑:
|
||
|
||
```typescript
|
||
// tools/builtin/bash.ts execute 方法内
|
||
const skillName = skillLoader.resolveSkillByPath(cwd || scriptPath);
|
||
if (skillName) {
|
||
const skillEnv = await skillSecretService.resolveEnv(skillName);
|
||
Object.assign(processEnv, skillEnv);
|
||
}
|
||
```
|
||
|
||
`SkillLoaderService` 新增 `resolveSkillByPath(absPath)` 方法:检查路径是否以某个 skill 目录为前缀,返回 skill name 或 null。
|
||
|
||
## 4. P1:Skill Runtime Executor
|
||
|
||
### 4.1 新增服务:SkillExecutorService
|
||
|
||
```typescript
|
||
// service/skill_executor.ts
|
||
interface SkillExecuteParams {
|
||
skillName: string;
|
||
input: Record<string, unknown>;
|
||
}
|
||
|
||
interface SkillExecuteResult {
|
||
success: boolean;
|
||
output?: Record<string, unknown>;
|
||
error?: string;
|
||
duration: number; // ms
|
||
}
|
||
|
||
@Provide()
|
||
@Scope(ScopeEnum.Singleton)
|
||
export class SkillExecutorService {
|
||
async execute(params: SkillExecuteParams): Promise<SkillExecuteResult>;
|
||
}
|
||
```
|
||
|
||
### 4.2 执行流程
|
||
|
||
```
|
||
Agent 调用 execute_skill({ name: "ocr-reader", input: { image_path: "/tmp/a.png" } })
|
||
→ SkillExecutorService.execute()
|
||
→ 1. 从 SkillLoaderService 获取 skillMeta,确认是 compute-entry
|
||
→ 2. 读取 skill.config.yaml:runtime / entrypoint / timeout / interface
|
||
→ 3. 验证 input 满足 interface.input(宽松模式:缺少 optional 字段不报错)
|
||
→ 4. SkillSecretService.resolveEnv(skillName) 获取 env map
|
||
→ 5. 根据 runtime 构建命令:
|
||
python → {skillDir}/.venv/bin/python {entrypoint} (win: .venv\Scripts\python)
|
||
node → node {entrypoint}
|
||
bash → bash {entrypoint}
|
||
dotnet → dotnet run --project {entrypoint}
|
||
→ 6. spawn 子进程:
|
||
cwd = skillDir
|
||
env = { ...process.env(白名单), ...skill-scoped env }
|
||
stdin = JSON.stringify(input)
|
||
timeout = config.timeout || 30000
|
||
→ 7. 收集 stdout(期望 JSON),stderr 作为日志
|
||
→ 8. 解析 stdout JSON → output,返回 SkillExecuteResult
|
||
```
|
||
|
||
### 4.3 新增 Agent 工具:execute_skill
|
||
|
||
```typescript
|
||
// tools/builtin/execute_skill.ts
|
||
const ExecuteSkillParams = Type.Object({
|
||
name: Type.String({ description: 'compute skill 名称' }),
|
||
input: Type.Record(Type.String(), Type.Unknown(), { description: '输入参数 JSON' }),
|
||
});
|
||
```
|
||
|
||
注册到 catalog:
|
||
|
||
```typescript
|
||
registerSchema({
|
||
name: 'execute_skill',
|
||
toolset: 'skill',
|
||
description: '执行 compute skill',
|
||
capability: 'compute',
|
||
visibility: 'skill',
|
||
});
|
||
```
|
||
|
||
工具注入条件:`buildSkillContext()` 检查 Agent 配置的 skills 中是否存在 compute-entry 类型,有则注入 `execute_skill` 工具。
|
||
|
||
### 4.4 Skill 端协议
|
||
|
||
entrypoint 脚本遵循 stdin/stdout JSON 协议:
|
||
|
||
- 从 stdin 读取 JSON 输入
|
||
- 环境变量中获取 secrets(如 `os.environ["OCR_API_KEY"]`)
|
||
- 将 JSON 结果写入 stdout
|
||
- 非零退出码 = 失败,stderr 内容作为错误信息
|
||
|
||
Python 示例:
|
||
|
||
```python
|
||
import sys, json, os
|
||
|
||
def main():
|
||
data = json.loads(sys.stdin.read())
|
||
api_key = os.environ["OCR_API_KEY"]
|
||
# ... 业务逻辑 ...
|
||
print(json.dumps({"text": "识别结果", "confidence": 0.95}))
|
||
|
||
if __name__ == "__main__":
|
||
main()
|
||
```
|
||
|
||
### 4.5 依赖安装改造
|
||
|
||
`SkillInstallerService.installDependencies()` 重构:
|
||
|
||
| 依赖类型 | 当前行为 | 改造后 |
|
||
|---------|---------|--------|
|
||
| `python` | `uv tool install` 全局 | skill 目录下 `uv venv .venv && uv pip install -r requirements.txt` |
|
||
| `node` | `pnpm add` 到主项目 | skill 目录下 `npm install` |
|
||
| `dotnet` | 不支持 | skill 目录下 `dotnet restore` |
|
||
| `system` | 不支持 | 执行 `check` 命令检测,未安装则报诊断 warning |
|
||
| `setup` | 不支持 | 首次安装后执行 `setup.posix` 或 `setup.win32` 脚本 |
|
||
|
||
### 4.6 环境变量白名单
|
||
|
||
spawn 子进程时不继承完整 `process.env`,只传递白名单:
|
||
|
||
```typescript
|
||
const ENV_WHITELIST = [
|
||
'PATH', 'HOME', 'USER', 'LANG', 'LC_ALL', 'TZ',
|
||
'TEMP', 'TMP', 'TMPDIR',
|
||
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY',
|
||
];
|
||
```
|
||
|
||
加上 skill-scoped env,确保主系统的敏感变量(DB 密码等)不泄露给 skill 代码。
|
||
|
||
## 5. P2:Agent Skills 标准兼容
|
||
|
||
### 5.1 字段映射
|
||
|
||
| Agent Skills 标准字段 | Neta 映射 | 说明 |
|
||
|---|---|---|
|
||
| `name` | 直接使用 | 新增命名规范验证 |
|
||
| `description` | 直接使用 | 最大 1024 字符 |
|
||
| `disable-model-invocation` | 新增支持 | hidden skill,不进入 prompt 索引 |
|
||
| `allowed-tools` | → `metadata.conditions.requires_tools` | 语义等价 |
|
||
| `compatibility` | → `metadata.compatibility` | 环境要求声明 |
|
||
| `license` | → `metadata.license` | 直接存储 |
|
||
|
||
### 5.2 名称规范验证
|
||
|
||
新增 `validateSkillName(name: string)` 函数,对齐 Agent Skills 标准:
|
||
|
||
- 只允许 `[a-z0-9-]`
|
||
- 1-64 字符
|
||
- 不允许首尾连字符、连续连字符
|
||
- 名称必须匹配父目录名
|
||
|
||
验证时机:安装(GitHub/ZIP)、创建(skill_manage 工具 / REST API)。不合规则拒绝并返回具体错误。已存在的不合规 skill 在 scanSkills 时产生 warning 诊断但仍加载。
|
||
|
||
### 5.3 Prompt 索引变更
|
||
|
||
`buildSkillsPrompt()` 输出区分 skill 类型:
|
||
|
||
```xml
|
||
<available_skills>
|
||
<skill name="llm-wiki" type="prompt" category="知识库">
|
||
知识库管理(用 read_skill 加载完整指令)
|
||
</skill>
|
||
<skill name="ocr-reader" type="compute-entry" category="多模态">
|
||
OCR 图片识别(用 execute_skill 调用)
|
||
输入: image_path(string,必填), language(string,默认auto)
|
||
</skill>
|
||
<skill name="minimax-pdf" type="compute-toolkit" category="文档生成">
|
||
PDF 生成/填表/重排版(用 read_skill 加载指令后通过 bash 执行脚本)
|
||
</skill>
|
||
</available_skills>
|
||
```
|
||
|
||
compute-entry 的索引包含 `interface.input` 摘要,Agent 无需 read_skill 即可直接调用 execute_skill。
|
||
|
||
hidden skill(`disable-model-invocation: true`)不出现在索引中,但仍可通过 `read_skill` 显式加载。
|
||
|
||
### 5.4 `read_skill` 返回格式改造
|
||
|
||
```typescript
|
||
// tools/builtin/read_skill.ts
|
||
async execute(_id, params) {
|
||
const skill = skillLoader.getSkill(params.name);
|
||
let result = skill.content;
|
||
|
||
// 附属文件:区分必读和可选
|
||
const config = skillLoader.getSkillConfig(params.name);
|
||
const requiredRefs = config?.references?.required || [];
|
||
const optionalRefs = (skill.files || []).filter(f => !requiredRefs.includes(f));
|
||
|
||
if (requiredRefs.length > 0) {
|
||
result += `\n\n<skill_required_references>`;
|
||
result += `\n⚠️ 执行此 skill 的任务前,你必须先用 read_skill_file 读取以下文档:`;
|
||
for (const ref of requiredRefs) {
|
||
result += `\n- ${ref}`;
|
||
}
|
||
result += `\n未读取这些文档就执行操作会导致错误。`;
|
||
result += `\n</skill_required_references>`;
|
||
}
|
||
|
||
if (optionalRefs.length > 0) {
|
||
result += `\n\n<skill_optional_references>`;
|
||
result += `\n以下文档可按需读取:`;
|
||
for (const ref of optionalRefs) {
|
||
result += `\n- ${ref}`;
|
||
}
|
||
result += `\n</skill_optional_references>`;
|
||
}
|
||
|
||
return textResult(result);
|
||
}
|
||
```
|
||
|
||
### 5.5 references 声明优先级
|
||
|
||
references 可以在两个位置声明:
|
||
|
||
1. `skill.config.yaml` 的 `references` 字段(compute skill)
|
||
2. SKILL.md frontmatter 的 `metadata.references` 字段(prompt skill 兼容)
|
||
|
||
优先级:`skill.config.yaml` > `SKILL.md frontmatter`。如果两者都存在,以 config 为准。对于纯 prompt skill(无 config),从 frontmatter 读取。如果两者都没有,fallback 到现有行为(列出所有附属文件为 optional)。
|
||
|
||
### 5.6 系统 prompt 层面的约束
|
||
|
||
`buildSkillsPrompt()` 的引导文本增加:
|
||
|
||
```
|
||
读取 skill 后,如果返回中包含 <skill_required_references>,你必须在执行任何操作前
|
||
先用 read_skill_file 逐一读取列出的所有文档。这不是建议,是强制要求。
|
||
```
|
||
|
||
## 6. P3:碰撞检测与诊断系统
|
||
|
||
### 6.1 诊断数据结构
|
||
|
||
```typescript
|
||
// service/skill_diagnostic.ts
|
||
interface SkillDiagnostic {
|
||
level: 'error' | 'warning' | 'info';
|
||
code: string; // 机器可读的诊断码
|
||
skillName: string;
|
||
message: string;
|
||
path?: string;
|
||
detail?: Record<string, unknown>;
|
||
}
|
||
```
|
||
|
||
### 6.2 诊断码清单
|
||
|
||
| code | level | 触发条件 |
|
||
|------|-------|---------|
|
||
| `NAME_COLLISION` | warning | 两个 skill 目录解析出相同 name |
|
||
| `NAME_INVALID` | warning | 名称不符合 `[a-z0-9-]` 规范 |
|
||
| `NAME_MISMATCH` | warning | frontmatter name 与目录名不一致 |
|
||
| `DESC_MISSING` | error | 缺少 description,skill 不加载 |
|
||
| `DESC_TOO_LONG` | warning | description 超过 1024 字符 |
|
||
| `FINGERPRINT_CHANGED` | info | 文件内容变更但未通过正式更新流程 |
|
||
| `ENV_NOT_CONFIGURED` | warning | compute skill 声明了 required env 但 DB 无对应 secret |
|
||
| `RUNTIME_UNAVAILABLE` | error | 声明 python/node/dotnet 但系统未安装 |
|
||
| `SYSTEM_DEP_MISSING` | warning | 系统依赖(ffmpeg 等)check 命令失败 |
|
||
| `VENV_MISSING` | warning | Python skill 缺少 .venv 目录 |
|
||
| `CONFIG_PARSE_ERROR` | error | skill.config.yaml 解析失败 |
|
||
|
||
### 6.3 碰撞检测
|
||
|
||
在 `scanSkills()` 中实现,逻辑参考 pi-mono:
|
||
|
||
- 维护 `Map<name, skillPath>` 和 `Set<realPath>`
|
||
- 同名 skill:保留先发现的(winner),后发现的记录 `NAME_COLLISION` 诊断
|
||
- symlink 去重:通过 `fs.realpath` 追踪,同一物理文件不重复加载
|
||
|
||
### 6.4 诊断收集与暴露
|
||
|
||
`SkillLoaderService` 新增:
|
||
|
||
```typescript
|
||
private diagnostics: SkillDiagnostic[] = [];
|
||
|
||
getDiagnostics(): SkillDiagnostic[] { return this.diagnostics; }
|
||
|
||
// scanSkills() 执行时清空并重新收集
|
||
async scanSkills(): Promise<void> {
|
||
this.diagnostics = [];
|
||
// ... 扫描过程中 push 诊断 ...
|
||
}
|
||
```
|
||
|
||
### 6.5 API
|
||
|
||
```
|
||
GET /admin/netaclaw/skill/diagnostics
|
||
→ 返回当前所有诊断信息列表
|
||
→ 支持 ?level=error 过滤
|
||
```
|
||
|
||
### 6.6 前端展示
|
||
|
||
`skills.vue` 页面顶部新增诊断横幅:
|
||
|
||
- error 级别:红色警告条,显示数量和摘要
|
||
- warning 级别:黄色提示条
|
||
- 点击展开查看详细诊断列表(skill 名称 + 诊断码 + 消息)
|
||
|
||
`skill-detail.vue` 抽屉新增"诊断"区域:只显示该 skill 相关的诊断。
|
||
|
||
## 7. 对现有系统的兼容性影响
|
||
|
||
### 7.1 skill_manage 工具
|
||
|
||
- 创建 skill 时新增名称规范验证,不合规则拒绝
|
||
- 如果 content 的 frontmatter 包含 `metadata.env`,自动在 DB 创建 envSchema 记录
|
||
- 不影响现有 create/edit/delete 流程
|
||
|
||
### 7.2 skill 管理页面
|
||
|
||
- 卡片新增 type 标签(prompt / compute-entry / compute-toolkit)
|
||
- 详情抽屉新增"配置"tab(env secrets 管理)
|
||
- 详情抽屉新增"诊断"tab
|
||
- 安装对话框不变
|
||
|
||
### 7.3 Agent 运行时
|
||
|
||
- `buildSkillsPrompt()` 输出格式微调(增加 type 属性),向后兼容
|
||
- `buildSkillContext()` 返回的 `skillTools` 条件性新增 `execute_skill`
|
||
- hidden skill 不进入 prompt 索引
|
||
- bash 工具执行 skill 目录下脚本时自动注入 skill-scoped env
|
||
|
||
### 7.4 现有 skill 零迁移
|
||
|
||
- `playwright-cli` 和 `llm-wiki` 没有 `skill.config.yaml`,自动识别为 prompt skill,行为不变
|
||
- skills-main 的 skill(minimax-pdf 等)安装后,如果没有 skill.config.yaml 也按 prompt skill 处理;后续可逐步补充 config 文件升级为 compute-toolkit
|
||
|
||
## 8. 新增文件清单
|
||
|
||
| 文件 | 说明 |
|
||
|------|------|
|
||
| `service/skill_secret.ts` | P0:密钥加密存储与解析 |
|
||
| `service/skill_executor.ts` | P1:compute-entry 执行器 |
|
||
| `service/skill_config.ts` | skill.config.yaml 解析器 |
|
||
| `tools/builtin/execute_skill.ts` | P1:Agent 工具 |
|
||
| `service/skill_diagnostic.ts` | P3:诊断收集(可合并到 skill_loader) |
|
||
|
||
## 9. 修改文件清单
|
||
|
||
| 文件 | 变更 |
|
||
|------|------|
|
||
| `entity/skill.ts` | 新增 secrets、envSchema 字段 |
|
||
| `service/skill_loader.ts` | 加载 skill.config.yaml、碰撞检测、诊断收集、resolveSkillByPath |
|
||
| `service/skill_installer.ts` | 依赖安装改造(skill 级 venv/node_modules)、setup 脚本执行 |
|
||
| `service/tool_resolver.ts` | execute_skill 工具实例化与条件注入(替代 skill_context.ts) |
|
||
| `tools/builtin/read_skill.ts` | 返回格式改造(required/optional references) |
|
||
| `tools/builtin/bash.ts` | 重构 createLocalBashOperations 增加 envOverride 参数,skill 路径检测注入 env |
|
||
| `controller/admin/skill.ts` | 新增 envSchema/secrets/diagnostics 端点 |
|
||
| `service/skill_loader.ts:buildSkillsPrompt` | 输出格式调整(type 属性、required references 提示) |
|
||
| `frontend: skills.vue` | 诊断横幅、type 标签 |
|
||
| `frontend: skill-detail.vue` | 重构为 tab 布局(基本信息 / 配置 / 诊断) |
|
||
| `shared/types/skill.types.ts` | 新增 SkillConfig、SkillDiagnostic,扩展 runtime 枚举 |
|
||
|
||
## 10. 架构评审补充
|
||
|
||
### 10.1 工具注入链路修正
|
||
|
||
`execute_skill` 的注入目标从 `skill_context.ts` 改为 `tool_resolver.ts`。在 `resolve()` 方法中,当检测到 Agent 配置的 skills 包含 compute-entry 类型时,实例化 `execute_skill` 工具并加入工具列表。逻辑位置在现有 `read_skill` 注入点(约 line 603)之后。
|
||
|
||
`skill_context.ts` 的 `buildSkillContext()` 函数标记为 deprecated,后续迁移到 tool_resolver 内部。
|
||
|
||
### 10.2 bash 工具 env 注入重构
|
||
|
||
`bash.ts` 的 `createLocalBashOperations()` 需要重构:
|
||
|
||
```typescript
|
||
// 当前签名
|
||
function createLocalBashOperations(shellConfig: ShellConfig): BashOperations
|
||
|
||
// 改造后
|
||
interface BashEnvProvider {
|
||
getAdditionalEnv(cwd: string): Promise<Record<string, string>>;
|
||
}
|
||
|
||
function createLocalBashOperations(
|
||
shellConfig: ShellConfig,
|
||
envProvider?: BashEnvProvider,
|
||
): BashOperations
|
||
```
|
||
|
||
`SkillLoaderService` 实现 `BashEnvProvider` 接口:根据 cwd 判断是否在 skill 目录下,是则返回 skill-scoped env。判断策略:严格前缀匹配 `skillsDir + sep + skillName + sep`,不做模糊匹配。
|
||
|
||
### 10.3 加密方案细化
|
||
|
||
secrets 字段的线格式:`base64(IV:16bytes || ciphertext || authTag:16bytes)`
|
||
|
||
```typescript
|
||
class SkillSecretService {
|
||
private readonly algorithm = 'aes-256-gcm';
|
||
private readonly ivLength = 16;
|
||
|
||
encrypt(plainObj: Record<string, string>): string {
|
||
const iv = crypto.randomBytes(this.ivLength);
|
||
const cipher = crypto.createCipheriv(this.algorithm, this.deriveKey(), iv);
|
||
const encrypted = Buffer.concat([cipher.update(JSON.stringify(plainObj), 'utf8'), cipher.final()]);
|
||
const authTag = cipher.getAuthTag();
|
||
return Buffer.concat([iv, encrypted, authTag]).toString('base64');
|
||
}
|
||
|
||
private deriveKey(): Buffer {
|
||
const raw = process.env.SKILL_SECRET_KEY || process.env.APP_SECRET;
|
||
if (!raw) throw new Error('SKILL_SECRET_KEY or APP_SECRET must be set');
|
||
return crypto.createHash('sha256').update(raw).digest(); // 32 bytes
|
||
}
|
||
}
|
||
```
|
||
|
||
密钥轮换:提供 `POST /admin/netaclaw/skill/rotateSecrets` 端点,用旧 key 解密所有 secrets 后用新 key 重新加密。密钥丢失时所有 secrets 不可恢复,需管理员重新配置。
|
||
|
||
### 10.4 DB 迁移
|
||
|
||
项目使用 TypeORM `synchronize: true`(开发环境)自动同步 entity 变更到数据库。生产环境通过数据库 MCP 工具直接操作,不需要编写 migration SQL 脚本。
|
||
|
||
实施时只需:
|
||
1. 在 `entity/skill.ts` 中添加新字段(`secrets`、`envSchema`、`skillTypeV2`)
|
||
2. 开发环境重启后 TypeORM 自动同步表结构
|
||
3. 生产环境通过 MCP `execute` 工具执行 ALTER TABLE(如需要)
|
||
|
||
现有行新字段为 NULL,不影响现有功能。`skillTypeV2` 在 scanSkills 时根据有无 skill.config.yaml 自动填充。
|
||
|
||
### 10.5 类型同步
|
||
|
||
`shared/types/skill.types.ts` 更新:
|
||
|
||
```typescript
|
||
export type SkillRuntime = 'node' | 'python' | 'bash' | 'dotnet';
|
||
export type SkillClassification = 'prompt' | 'compute-entry' | 'compute-toolkit';
|
||
```
|
||
|
||
DB 的 `skillType` 字段(compute/llm/multimodal)保留不变,它描述的是 skill 的能力类别。新增 `skill_type_v2` 字段存储三分类,两者正交。
|
||
|
||
### 10.6 Agent 分配 skill 时的类型校验
|
||
|
||
Agent 编辑页(`agent-edit.vue`)的 skill 选择器改造:
|
||
|
||
**前端改造:**
|
||
- 可选 Skill 列表中每个 skill 显示分类标签(prompt / compute-entry / compute-toolkit)
|
||
- 已选择列表中同样显示分类标签
|
||
- 当选择 compute-entry 或 compute-toolkit skill 时,如果该 Agent 缺少对应工具权限,显示黄色 warning 提示
|
||
|
||
**后端校验:**
|
||
|
||
在 Agent 保存接口(`POST /admin/netaclaw/agent/update`)中新增校验逻辑:
|
||
|
||
```typescript
|
||
// controller/agent.ts 的 update 方法中
|
||
if (body.skills?.length) {
|
||
const warnings: string[] = [];
|
||
for (const skillName of body.skills) {
|
||
const classification = this.skillLoader.getSkillClassification(skillName);
|
||
if (classification === 'compute-entry') {
|
||
// 检查 Agent 的 tool governance 是否允许 execute_skill
|
||
// 通过 tool_resolver 的 catalog 检查 execute_skill 是否在 Agent 可用工具列表中
|
||
const toolNames = collectToolNames({ hasSkills: true });
|
||
if (!toolNames.includes('execute_skill')) {
|
||
warnings.push(`Skill "${skillName}" 是 compute-entry 类型,但 execute_skill 工具未启用`);
|
||
}
|
||
}
|
||
if (classification === 'compute-toolkit') {
|
||
const toolNames = collectToolNames({ hasSkills: true });
|
||
if (!toolNames.includes('bash')) {
|
||
warnings.push(`Skill "${skillName}" 是 compute-toolkit 类型,但 bash 工具未启用`);
|
||
}
|
||
}
|
||
}
|
||
// warnings 不阻塞保存,随响应返回
|
||
}
|
||
```
|
||
|
||
`/admin/netaclaw/skill/metas` 端点返回数据新增 `classification` 字段,前端据此渲染标签。
|
||
|
||
### 10.7 基础设施文件过滤
|
||
|
||
`collectFiles()` 新增排除列表:
|
||
|
||
```typescript
|
||
const INFRA_FILES = new Set([
|
||
'skill.config.yaml', 'requirements.txt', 'package.json',
|
||
'package-lock.json', 'tsconfig.json', '.env',
|
||
]);
|
||
```
|
||
|
||
这些文件不出现在 `read_skill` 返回的 `<skill_files>` 列表中,Agent 不会误读。
|
||
|
||
### 10.8 Skill 执行审计日志
|
||
|
||
`SkillExecutorService.execute()` 执行完成后写入结构化日志:
|
||
|
||
```typescript
|
||
this.logger.info('[SkillExecutor] %s executed by agent=%s duration=%dms success=%s',
|
||
params.skillName, agentId, result.duration, result.success);
|
||
```
|
||
|
||
失败时额外记录 stderr 摘要(截断到 500 字符)。
|
||
|
||
### 10.9 setup 脚本安全约束
|
||
|
||
安装时执行 setup 脚本增加限制:
|
||
|
||
**后端(skill_installer.ts):**
|
||
- timeout: 120s
|
||
- 只允许 `source === 'github'` 或 `source === 'zip'`(管理员手动上传)的 skill 执行 setup
|
||
- `source === 'local'`(通过 skill_manage 工具创建)的 skill 不允许 setup 脚本
|
||
- 执行前检查 setup 脚本路径不包含 `..`(路径穿越防护)
|
||
|
||
```typescript
|
||
// skill_installer.ts installDependencies 中
|
||
const setupKey = process.platform === 'win32' ? 'win32' : 'posix';
|
||
const setupScript = config?.setup?.[setupKey];
|
||
if (setupScript) {
|
||
const origin = await this.registry.readOrigin(skillName);
|
||
const allowedSources = ['github', 'zip'];
|
||
if (!origin || !allowedSources.includes(origin.source)) {
|
||
logs.push(`[setup] 跳过: 仅 GitHub/ZIP 安装的 skill 允许执行 setup 脚本`);
|
||
} else if (setupScript.includes('..')) {
|
||
logs.push(`[setup] 跳过: setup 脚本路径包含路径穿越`);
|
||
} else {
|
||
// 执行 setup,timeout 120s
|
||
}
|
||
}
|
||
```
|
||
|
||
**前端(skills.vue 安装对话框):**
|
||
- 安装完成后,如果 skill 包含 setup 脚本,弹出确认对话框:
|
||
"此 Skill 包含安装脚本({scriptName}),是否执行?"
|
||
- 用户确认后才调用 `/installDeps` 端点
|
||
|
||
### 10.10 references.routes 的实现策略
|
||
|
||
routes 匹配在 server 端执行。`read_skill` 工具新增可选参数 `task`:
|
||
|
||
```typescript
|
||
const ReadSkillParams = Type.Object({
|
||
name: Type.String(),
|
||
task: Type.Optional(Type.String({ description: '当前任务描述,用于自动匹配需要读取的文档' })),
|
||
});
|
||
```
|
||
|
||
server 端对 `task` 做关键词匹配(`match` 数组中的词是否出现在 task 中),命中则将对应 `required_refs` 的内容直接拼接到返回结果中,Agent 无需二次调用 `read_skill_file`。未命中则 fallback 到 `<skill_required_references>` 列表提示。
|
||
|
||
### 10.11 Symlink 去重
|
||
|
||
`scanSkills()` 中维护 `Set<string>` 存储已加载 skill 的 realpath。对每个 skill 目录调用 `fs.realpath()` 获取真实路径,如果已在 set 中则跳过(不产生碰撞诊断,因为是同一物理目录)。
|
||
|
||
```typescript
|
||
const realPathSet = new Set<string>();
|
||
// 在加载每个 skill 时
|
||
const realPath = await fs.realpath(path.join(this.skillsDir, entry.name)).catch(() => null);
|
||
if (realPath && realPathSet.has(realPath)) continue; // 静默跳过 symlink 重复
|
||
if (realPath) realPathSet.add(realPath);
|
||
```
|
||
|
||
### 10.12 skill_context.ts 废弃
|
||
|
||
`skill_context.ts` 的 `buildSkillContext()` 函数标记为 `@deprecated`,添加注释指向 `tool_resolver.ts` 中的新实现。不删除文件,避免破坏可能存在的外部引用。后续版本清理。
|
||
|
||
```typescript
|
||
/**
|
||
* @deprecated 使用 tool_resolver.ts 中的 skill 工具注入逻辑替代。
|
||
* 此函数不再被主链路调用。
|
||
*/
|
||
export function buildSkillContext(...) { ... }
|
||
```
|