组装:示例插件
对应官方:
skill/shell/fs能力包 + profile/bundle 组装
框架写完了,现在用四个真实插件演示「一切皆插件」的威力。每个插件都演示一种注册模式。
1. system-prompt:服务贡献 + 可逆效应
插件往 ctx.systemPrompt 服务里注册一段提示词片段,卸载时移除:
/**
* system-prompt 插件:向 ctx.systemPrompt 服务贡献提示词片段。
* 演示「注册是可逆效应」:卸载时移除自己贡献的片段。
*/
import type { PluginDef } from "../src/context.ts";
import { SystemPrompt } from "../src/agent.ts";
export const systemPromptPlugin: PluginDef = {
name: "system-prompt",
apply: (ctx) => {
const sp = ctx.get<SystemPrompt>("systemPrompt") ?? ctx.service("systemPrompt", new SystemPrompt());
sp.addSection("identity", "你是 mini-dsh,一个基于 DeepSeek 的智能体。先思考,再调用工具,最后给出简洁的回答。");
return () => sp.removeSection("identity");
},
};export const systemPromptPlugin: PluginDef = {
name: "system-prompt",
apply: (ctx) => {
const sp = ctx.get<SystemPrompt>("systemPrompt") ?? ctx.service("systemPrompt", new SystemPrompt());
sp.addSection("identity", "你是 mini-dsh,...");
return () => sp.removeSection("identity"); // 可逆效应
},
};SystemPrompt 服务(在 src/agent.ts 里)维护一个片段 Map,render() 拼成最终提示词:
export class SystemPrompt {
private sections = new Map<string, string>();
addSection(key: string, text: string): void { this.sections.set(key, text); }
removeSection(key: string): void { this.sections.delete(key); }
render(): string {
return [...this.sections.entries()].map(([k, v]) => `## ${k}\n${v}`).join("\n\n");
}
}以后想加「安全规则」「输出格式」「工具使用指南」,都是新插件往这个服务里加片段 —— 核心循环零改动。
2. shell:能力即工具
能力(执行命令)以工具形态暴露给模型:
/**
* shell 插件:提供 run_bash 工具,让模型能在本地执行命令。
* 演示「能力缝隙」:能力(执行命令)以工具形态暴露给模型。
* 注意:真实部署应把 shell 放进沙箱(官方用 sandbox/e2b 等后端)。
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { PluginDef } from "../src/context.ts";
import { tool, type ToolRegistry } from "../src/tools.ts";
const execFileAsync = promisify(execFile);
export const shellPlugin: PluginDef = {
name: "shell",
inject: ["tools"],
apply: (ctx) => {
const tools = ctx.get<ToolRegistry>("tools")!;
return tools.register(
tool(
"run_bash",
"在本地 shell 中执行一条命令(如 ls、cat、node),返回 stdout 与 stderr。",
{
type: "object",
properties: {
command: { type: "string", description: "要执行的 shell 命令" },
timeout: { type: "integer", description: "超时秒数,默认 30" },
},
required: ["command"],
},
async ({ command, timeout }: { command: string; timeout?: number }) => {
try {
const { stdout, stderr } = await execFileAsync("/bin/bash", ["-c", command], {
timeout: (timeout ?? 30) * 1000,
maxBuffer: 1024 * 1024,
});
const out = stdout.trim();
const err = stderr.trim();
if (out && err) return `stdout:\n${out}\n\nstderr:\n${err}`;
return out || err || "(无输出)";
} catch (err: any) {
const detail = err.stderr || err.message || String(err);
return `<run_bash 失败: ${detail.slice(0, 500)}>`;
}
},
),
);
},
};export const shellPlugin: PluginDef = {
name: "shell",
inject: ["tools"], // 声明依赖:工具注册表
apply: (ctx) => {
const tools = ctx.get<ToolRegistry>("tools")!;
return tools.register(tool("run_bash", "在本地 shell 中执行一条命令...", {
type: "object",
properties: { command: { type: "string" }, timeout: { type: "integer" } },
required: ["command"],
}, async ({ command, timeout }) => {
// execFile 而非 exec:参数不走 shell 解析,减少注入面
const { stdout, stderr } = await execFileAsync("/bin/bash", ["-c", command], {
timeout: (timeout ?? 30) * 1000, maxBuffer: 1024 * 1024,
});
return stdout.trim() || stderr.trim() || "(无输出)";
}));
},
};安全的边界
run_bash 是给模型的一把万能钥匙,mini 版直接放行(教学用途)。生产环境必须把它放进沙箱 —— 这正是官方 sandbox / e2b 包存在的意义:沙箱作为 ctx.sandbox 服务,所有 spawn 动作都经它包装。能力越强,越需要策略层(见 tools/pre-execute 的用法)。
3. filesystem:策略即插件
读写文件 + 路径沙箱:模型给的任何路径都被解析并限制在工作根目录内,越界直接抛错:
/**
* filesystem 插件:read_file / write_file 工具,工作目录沙箱化。
* 演示「策略即插件」:路径穿越防护直接写在工具边界上,模型不可绕过。
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, normalize, relative, resolve } from "node:path";
import type { PluginDef } from "../src/context.ts";
import { tool, type ToolRegistry } from "../src/tools.ts";
export interface FsOptions {
/** 允许访问的工作根目录,默认 process.cwd() */
root?: string;
}
export function fsPlugin(opts: FsOptions = {}): PluginDef {
const root = resolve(opts.root ?? process.cwd());
/** 把模型给的路径解析并限制在 root 内,越界直接抛错 */
function safePath(p: string): string {
const abs = isAbsolute(p) ? p : join(root, p);
const rel = relative(root, abs);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(`路径越界(仅允许访问 ${root}): ${p}`);
}
return normalize(abs);
}
return {
name: "filesystem",
inject: ["tools"],
apply: (ctx) => {
const tools = ctx.get<ToolRegistry>("tools")!;
const disposers = [
tools.register(
tool(
"read_file",
"读取工作目录内的文本文件,返回内容。路径相对工作目录或绝对路径。",
{
type: "object",
properties: { path: { type: "string", description: "文件路径" } },
required: ["path"],
},
({ path }: { path: string }) => readFileSync(safePath(path), "utf8"),
),
),
tools.register(
tool(
"write_file",
"把内容写入工作目录内的文件(覆盖)。",
{
type: "object",
properties: {
path: { type: "string", description: "文件路径" },
content: { type: "string", description: "要写入的内容" },
},
required: ["path", "content"],
},
({ path, content }: { path: string; content: string }) => {
const abs = safePath(path);
mkdirSync(dirname(abs), { recursive: true });
writeFileSync(abs, content, "utf8");
return `已写入 ${path}(${content.length} 字符)`;
},
),
),
];
return () => disposers.forEach((d) => d());
},
};
}/** 把模型给的路径解析并限制在 root 内,越界直接抛错 */
function safePath(p: string): string {
const abs = isAbsolute(p) ? p : join(root, p);
const rel = relative(root, abs);
if (rel.startsWith("..") || isAbsolute(rel)) {
throw new Error(`路径越界(仅允许访问 ${root}): ${p}`);
}
return normalize(abs);
}为什么叫「策略即插件」?因为防护写在工具边界上 —— 模型无论怎么构造路径(../etc/passwd、绝对路径、符号链接),都过不了 safePath 这一关,不存在「换个说法就绕过」的旁路。官方称之为「在做出决定的那个操作里强制执行」。
4. skills:Markdown 技能包 -> 工具
技能是 skills/*.md(frontmatter + 正文):
---
name: calculator
description: 用 Node.js 计算数学表达式,适合四则运算、百分比、单位换算等
---
# 计算器技能
1. 把用户的算式转换成安全的 JavaScript 表达式...
2. 用 node -e "console.log(<表达式>)" 执行并读取输出
...插件把它们变成两个工具:list_skills(查看有哪些技能)和 use_skill(name, task)(加载技能正文执行):
/**
* skills 插件:Markdown 技能包 -> 工具。
*
* 官方概念:skill 是「可注入的指令包」。mini 版的实现路径:
* 技能是 skills/ 目录下的 Markdown(带 frontmatter),经 use_skill 工具
* 以工具结果的形式注入上下文 —— 模型自己决定何时调用它。
* 这个路径让技能天然可审计、可组合,且不需要改动 agent 循环。
*/
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import type { PluginDef } from "../src/context.ts";
import { tool, type ToolRegistry } from "../src/tools.ts";
export interface Skill {
name: string;
description: string;
body: string;
}
/** 解析带 frontmatter 的 Markdown 技能文件 */
function parseSkill(file: string): Skill {
const text = readFileSync(file, "utf8");
const match = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/.exec(text);
if (!match) throw new Error(`技能文件缺少 frontmatter: ${file}`);
const meta: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const i = line.indexOf(":");
if (i > 0) meta[line.slice(0, i).trim()] = line.slice(i + 1).trim();
}
return { name: meta.name ?? file, description: meta.description ?? "", body: match[2].trim() };
}
export interface SkillsOptions {
/** 技能目录,默认 ./skills */
dir?: string;
}
export function skillsPlugin(opts: SkillsOptions = {}): PluginDef {
const dir = join(process.cwd(), opts.dir ?? "skills");
function loadAll(): Skill[] {
return readdirSync(dir)
.filter((f) => f.endsWith(".md"))
.map((f) => parseSkill(join(dir, f)));
}
return {
name: "skills",
inject: ["tools"],
apply: (ctx) => {
const tools = ctx.get<ToolRegistry>("tools")!;
const disposers = [
tools.register(
tool(
"list_skills",
"列出当前可用的技能及其用途说明。",
{ type: "object", properties: {}, required: [] },
() => loadAll().map((s) => `- ${s.name}: ${s.description}`).join("\n") || "(没有技能)",
),
),
tools.register(
tool(
"use_skill",
"按名字加载一个技能的完整指令并开始执行它。返回技能正文,请据此完成任务。",
{
type: "object",
properties: {
name: { type: "string", description: "技能名(见 list_skills)" },
task: { type: "string", description: "要用该技能完成的具体任务" },
},
required: ["name", "task"],
},
({ name, task }: { name: string; task: string }) => {
const skill = loadAll().find((s) => s.name === name);
if (!skill) throw new Error(`未知技能: ${name}(可用: ${loadAll().map((s) => s.name).join(", ")})`);
return `# 技能:${skill.name}\n${skill.body}\n\n# 当前任务\n${task}`;
},
),
),
];
return () => disposers.forEach((d) => d());
},
};
}tools.register(tool(
"use_skill",
"按名字加载一个技能的完整指令并开始执行它。返回技能正文,请据此完成任务。",
{ type: "object", properties: { name: {...}, task: {...} }, required: ["name", "task"] },
({ name, task }) => {
const skill = loadAll().find((s) => s.name === name);
if (!skill) throw new Error(`未知技能: ${name}`);
return `# 技能:${skill.name}\n${skill.body}\n\n# 当前任务\n${task}`;
},
));这个设计的精妙之处:技能正文通过工具结果进入上下文 —— 它走的是最普通的 tool/result 通道,不需要改动 agent 循环的任何一行。技能天然可审计(每次使用都留在日志里)、可组合、可热更新(改 md 即生效)。模型的调用序列会是:
assistant: 让我先看看有什么技能 -> tool:list_skills
assistant: 用户要计算,加载计算器技能 -> tool:use_skill(name=calculator)
assistant: 按技能步骤执行 -> tool:run_bash(node -e ...)
assistant: 给出最终答案组装:boot.ts(官方 profile 思想的简化)
官方一个运行的 dsh 是「按层组合出来的插件树」,由 profile 声明挂哪些 bundle。mini 版把它简化成一个 boot.ts:
/**
* boot:组装一个可运行的 mini-dsh(对应官方的 profile/bundle 思想)。
*
* 组装 = 注册服务 + 挂载插件。哪一层想要什么能力,就挂什么插件:
* 想让它会写文件,挂 filesystem;想让它会执行命令,挂 shell。
*/
import { Agent } from "./src/agent.ts";
import { Context } from "./src/context.ts";
import { DeepSeekProvider, type DeepSeekOptions, ScriptedProvider } from "./src/llm.ts";
import { Sessions } from "./src/session.ts";
import { ToolRegistry } from "./src/tools.ts";
import { systemPromptPlugin } from "./plugins/system-prompt.ts";
import { shellPlugin } from "./plugins/shell.ts";
import { fsPlugin } from "./plugins/filesystem.ts";
import { skillsPlugin } from "./plugins/skills.ts";
export interface BootOptions {
llm?: DeepSeekOptions;
/** 测试/演示时传入脚本化 Provider,跳过真实 API */
provider?: "deepseek" | "scripted";
scriptedResponses?: import("./src/session.ts").ChatMessage[];
/** 文件系统插件的工作根目录(默认 cwd) */
fsRoot?: string;
/** 技能目录(默认 ./skills) */
skillsDir?: string;
}
export async function buildContext(opts: BootOptions = {}): Promise<Context> {
const ctx = new Context();
// ---- 服务层:插件依赖它们 ----
ctx.service("tools", new ToolRegistry(ctx));
ctx.service("sessions", new Sessions());
ctx.service(
"llm",
opts.provider === "scripted"
? new ScriptedProvider(opts.scriptedResponses ?? [])
: new DeepSeekProvider(opts.llm ?? {}),
);
// ---- 插件层:按需组合能力 ----
ctx.plugin(systemPromptPlugin);
ctx.plugin(shellPlugin);
ctx.plugin(fsPlugin({ root: opts.fsRoot }));
ctx.plugin(skillsPlugin({ dir: opts.skillsDir }));
await ctx.start();
return ctx;
}
/** 组装好上下文,并创建挂上 llm 服务的 Agent */
export async function buildAgent(opts: BootOptions = {}) {
const ctx = await buildContext(opts);
const llm = ctx.get<DeepSeekProvider | ScriptedProvider>("llm")!;
return { ctx, agent: new Agent(ctx, { provider: llm }) };
}export async function buildContext(opts: BootOptions = {}): Promise<Context> {
const ctx = new Context();
// ---- 服务层:插件依赖它们 ----
ctx.service("tools", new ToolRegistry(ctx));
ctx.service("sessions", new Sessions());
ctx.service("llm", /* DeepSeek 或 Scripted */);
// ---- 插件层:按需组合能力 ----
ctx.plugin(systemPromptPlugin);
ctx.plugin(shellPlugin);
ctx.plugin(fsPlugin({ root: opts.fsRoot }));
ctx.plugin(skillsPlugin({ dir: opts.skillsDir }));
await ctx.start();
return ctx;
}「想让它会写文件,挂 fsPlugin;想让它会执行命令,挂 shellPlugin」 —— 能力即插即拔,这正是「Everything is a Plugin」的日常形态。provider: "scripted" 选项让整个组装可以在没有 key 的环境里跑起来(测试、CI、演示全靠它)。
本章回顾
- 四种注册模式:服务贡献(提示词)、能力即工具(shell)、策略即插件(路径沙箱)、内容即工具(技能)
- 技能通过
tool/result通道注入上下文,零改动核心循环 - boot.ts 是 profile 思想的简化:服务打底、插件按需组合
下一步:CLI 与启动 →