Assembly: Example Plugins
Official counterpart:
skill/shell/fscapability packages + profile/bundle composition
The framework is done. Now four real plugins demonstrate "everything is a plugin" — each one shows a registration pattern.
1. system-prompt: Service Contribution + Reversible Effect
The plugin registers a prompt section into the ctx.systemPrompt service and removes it on unload:
/**
* 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"); // reversible effect
},
};The SystemPrompt service (in src/agent.ts) keeps a section map and render() assembles the final prompt:
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");
}
}Want to add "safety rules", "output format", or "tool usage guide"? Each is just a new plugin adding a section — the core loop stays untouched.
2. shell: Capability as a Tool
A capability (running commands) is exposed to the model in tool form:
/**
* 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"], // declares its dependency: the tool registry
apply: (ctx) => {
const tools = ctx.get<ToolRegistry>("tools")!;
return tools.register(tool("run_bash", "Execute a command in the local shell...", {
type: "object",
properties: { command: { type: "string" }, timeout: { type: "integer" } },
required: ["command"],
}, async ({ command, timeout }) => {
// execFile rather than exec: arguments never go through shell parsing, smaller injection surface
const { stdout, stderr } = await execFileAsync("/bin/bash", ["-c", command], {
timeout: (timeout ?? 30) * 1000, maxBuffer: 1024 * 1024,
});
return stdout.trim() || stderr.trim() || "(no output)";
}));
},
};Know the boundary
run_bash is a master key handed to the model; mini-dsh lets it through directly (for teaching). Production must put it inside a sandbox — that's exactly what the official sandbox / e2b packages exist for: the sandbox is a ctx.sandbox service that wraps every spawn. The stronger the capability, the more it needs a policy layer (see tools/pre-execute).
3. filesystem: Policy as a Plugin
Read/write files + path sandboxing: every path the model gives is resolved and confined to the workspace root; anything outside throws:
/**
* 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());
},
};
}/** Resolve a model-provided path and confine it to root; throw if it escapes */
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(`Path escapes workspace (only ${root} is allowed): ${p}`);
}
return normalize(abs);
}Why "policy as a plugin"? Because the guard lives on the tool boundary — no matter how the model constructs the path (../etc/passwd, absolute paths, symlinks), it cannot pass safePath. There is no side channel that "uses different wording". The official calls this "enforce the decision in the operation that makes it".
4. skills: Markdown Skill Packs -> Tools
Skills are skills/*.md files (frontmatter + body):
---
name: calculator
description: Compute math expressions with Node.js — arithmetic, percentages, unit conversion
---
# Calculator Skill
1. Convert the user's expression into a safe JavaScript expression...
2. Run node -e "console.log(<expression>)" and read the output
...The plugin turns them into two tools: list_skills (what skills exist) and use_skill(name, task) (load and execute):
/**
* 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",
"Load a skill's full instructions by name and start executing it. Returns the skill body; follow it to complete the task.",
{ type: "object", properties: { name: {...}, task: {...} }, required: ["name", "task"] },
({ name, task }) => {
const skill = loadAll().find((s) => s.name === name);
if (!skill) throw new Error(`Unknown skill: ${name}`);
return `# Skill: ${skill.name}\n${skill.body}\n\n# Current task\n${task}`;
},
));The elegance: the skill body enters the context through the ordinary tool/result channel — no change to the agent loop whatsoever. Skills are naturally auditable (every use is in the log), composable, and hot-reloadable (edit the md, it takes effect). The model's call sequence looks like:
assistant: let me see what skills exist -> tool:list_skills
assistant: the user wants a calculation, load the calculator skill -> tool:use_skill(name=calculator)
assistant: follow the skill steps -> tool:run_bash(node -e ...)
assistant: give the final answerAssembly: boot.ts (the official profile idea, simplified)
A running official dsh is a "plugin tree composed from layers", with profiles declaring which bundles to mount. mini-dsh simplifies this into one 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();
// ---- service layer: plugins depend on these ----
ctx.service("tools", new ToolRegistry(ctx));
ctx.service("sessions", new Sessions());
ctx.service("llm", /* DeepSeek or Scripted */);
// ---- plugin layer: compose capabilities on demand ----
ctx.plugin(systemPromptPlugin);
ctx.plugin(shellPlugin);
ctx.plugin(fsPlugin({ root: opts.fsRoot }));
ctx.plugin(skillsPlugin({ dir: opts.skillsDir }));
await ctx.start();
return ctx;
}"Want it to write files? Mount fsPlugin. Want it to run commands? Mount shellPlugin." — capabilities plug in and out; that's "Everything is a Plugin" in daily form. The provider: "scripted" option lets the whole assembly run without a key (tests, CI, and demos all rely on it).
Recap
- Four registration patterns: service contribution (prompt), capability as tool (shell), policy as plugin (path sandbox), content as tool (skills)
- Skills inject context through the
tool/resultchannel — zero core changes - boot.ts is the profile idea simplified: services as the base, plugins composed on demand
Next: CLI & Startup →