19 KiB
TauriTavern Skill Script 指南
本文档面向 Skill 开发者,说明如何在 Skill 包的 scripts/ 目录中编写可被 Agent 执行的 JavaScript 脚本。它覆盖脚本格式、Runtime 模块 API、文件系统读写边界、模块导入规则(含自带第三方库)、以及沙箱限制。
本文记录的是当前已落地的
skill.run_script能力,不是方案讨论。长期开发以本文、docs/Agent/Skill.md与docs/API/Skill.md为准。
1. 概述
Skill 是 Agent 按需读取的本地知识包,一个 Skill 的目录结构如下:
my-skill/
SKILL.md
references/
examples/
assets/
scripts/
agents/tauritavern.json
scripts/ 目录下可以放置 .js 脚本文件。Agent 在运行期间通过 skill.run_script 工具在隔离的 QuickJS 沙箱中执行这些脚本,执行结果以 JSON 形式返回给模型作为后续上下文。
脚本不是自动执行的——只有当 Agent 显式调用 skill.run_script 时才会运行。Skill 通过 SKILL.md 告诉模型有哪些脚本可用、每个脚本接受什么参数、返回什么结果。
2. 脚本格式
脚本使用 ES Module 语法(export / import),必须导出一个 default 函数或一个具名的 main 函数。引擎优先调用 default(args),不存在时回退到 main(args)。如果两者都不存在,脚本会得到明确报错(而非静默返回 undefined)。
入口函数可以是同步或 async 函数。如果返回的是 Promise,引擎会 await 到它 settle(rejection 作为 JS 异常传播)。脚本也支持顶层 await(top-level await),但仅限能 settle 的 Promise——沙箱内没有宿主异步 API,永远 pending 的 await 会导致执行错误。
args 是调用时传入的参数对象(JSON 可序列化),由 Agent 在 skill.run_script 的 args 字段中提供。返回值经 JSON.stringify 序列化后传回宿主,因此必须是 JSON 可序列化的值。循环引用、BigInt、函数、Symbol 会在 JSON.stringify 阶段报错;undefined 返回值会被明确拒绝(返回 null 显式表示空值)。
// 方式 1:导出 default 函数(推荐)
export default function (args) {
const { input, options } = args;
return {
success: true,
data: processInput(input),
};
}
// 方式 2:导出 main 函数
export function main(args) {
return { result: 'processed' };
}
脚本名称(skill.run_script 的 script 参数)是 scripts/ 目录下不带 .js 扩展名的文件名,且必须匹配 ^[a-z0-9][a-z0-9-]*$(小写字母、数字、连字符,字母或数字开头)。例如 scripts/helper.js 对应 script 名 helper,scripts/parse-xml.js 对应 parse-xml。
3. Runtime 模块(@tauritavern/runtime/v1)
宿主能力经版本化 ES Module 导入,沙箱不注入任何全局对象。脚本通过 import 从 @tauritavern/runtime/v1 获取宿主能力:
import { context, workspace, log } from '@tauritavern/runtime/v1';
导出表:
| 导出 | 读写 | 说明 |
|---|---|---|
workspace |
读写 | 受沙箱策略门控的文件 API |
context |
只读 | 与宿主状态隔离的 worldInfo + variables 快照 |
log |
只写 | 经宿主 tracing 输出的日志 API |
除此之外没有 process、Buffer、fs、http、crypto、setTimeout、setInterval 等 Node 或浏览器 API。
3.1 workspace — 文件 API
workspace 提供受限的文件读写能力。所有路径相对于当前 run 的 workspace 根目录,经过路径清洗后必须落在当前 invocation Workspace policy 的 visible_roots(读)或 writable_roots(写)内。绝对路径和 .. 逃逸会被拒绝。
import { workspace } from '@tauritavern/runtime/v1';
// 读取文件内容(路径相对 workspace 根目录)
const content = workspace.readText('output/config.json');
// 写入文件内容(自动创建父目录)
workspace.writeText('output/result.txt', 'Hello World');
// 列出目录下的条目名(相对路径前缀)
// 无参:列出 workspace 根目录顶层条目名
// 有参:列出指定目录下条目的 workspace 相对路径
const files = workspace.listFiles('output');
// 返回: ['a.md', 'b.txt', ...]
// 检查文件或目录是否存在
const exists = workspace.exists('output/config.json');
| 方法 | 签名 | 说明 |
|---|---|---|
readText(path) |
(path: string) → string |
读取 UTF-8 文本文件;路径必须在 visible roots 内 |
writeText(path, content) |
(path: string, content: string) → void |
写入 UTF-8 文本文件;路径必须是 writable root 的子项;自动创建父目录 |
listFiles(path?) |
(path?: string) → string[] |
列出目录条目;无参列出根目录条目名,有参列出相对路径;读权限同 readText |
exists(path) |
(path: string) → boolean |
检查文件或目录是否存在;路径不在 visible roots 内时返回 false 而非抛错 |
3.2 context.worldInfo — 世界书快照(只读)
context.worldInfo 是当前 run 启动时预取的激活世界书快照。它是普通 JSON;脚本可以在本次执行中处理这份副本,但不会修改宿主世界书。
import { context } from '@tauritavern/runtime/v1';
const entries = context.worldInfo.entries;
// [{ uid, ref, content, constant, position, displayName, world }, ...]
const wanted = new Set(['worldinfo:lore#1', 'worldinfo:chars#2']);
const selected = entries.filter(entry => wanted.has(entry.ref));
每个条目的字段:
| 字段 | 类型 | 说明 |
|---|---|---|
uid |
string |
条目 UID |
ref |
string |
条目引用键,格式 worldinfo:{world}#{uid} |
content |
string |
条目正文内容 |
constant |
boolean |
是否为常驻条目 |
position |
string? |
插入位置(可能为空) |
displayName |
string? |
显示名称(可能为空) |
world |
string |
所属世界书名称 |
3.3 context.variables — SillyTavern 变量快照(只读)
context.variables 是当前 run 的 SillyTavern 变量快照,包含 local 和 global 两个普通 JSON 对象。值保持快照中的原始 JSON 类型。
import { context } from '@tauritavern/runtime/v1';
// 读取 local 变量
const score = context.variables.local.score;
const name = context.variables.local['name'];
// 检查 local 变量是否存在
const hasName = Object.hasOwn(context.variables.local, 'name');
// 读取 global 变量
const theme = context.variables.global.theme;
变量不存在时得到标准 JavaScript undefined,可按需使用 ?? 提供默认值。修改这份对象只影响本次脚本执行,不会写回 SillyTavern。
3.4 log — 日志 API
log 将日志输出到宿主的日志系统,供开发者调试。日志不会进入 Agent 上下文或聊天消息。
import { log } from '@tauritavern/runtime/v1';
log.info('Processing started');
log.warn('Deprecated format detected');
log.error('Failed to parse input');
log.debug('Debug value: ' + JSON.stringify(someValue));
log 的各方法接受一个 string 参数;传入非字符串会抛出类型错误。输出前缀为 [skill-script],写入宿主日志系统。
4. 文件系统读写边界
workspace 的基础读写权限来自 Agent Profile,并由当前 invocation 的 Workspace manifest 投影为最终 policy。这些 roots 是相对 workspace 根目录的子目录名。
默认 Agent Profile 的 visible / writable roots 为:
| Root | 说明 |
|---|---|
output |
Agent 最终输出(artifact)目录 |
scratch |
临时草稿目录 |
plan |
计划文件目录 |
summaries |
摘要文件目录 |
persist |
持久化目录 |
run-workspace/
output/ ← 可读可写
scratch/ ← 可读可写
plan/ ← 可读可写
summaries/ ← 可读可写
persist/ ← 可读可写
input/ ← 不可读写
tool-args/ ← 不可读写
tool-results/← 默认不可见;return-mode 子 Agent 中只读
...
规则:
- 读操作(
readText/listFiles):路径清洗后必须落在某个 visible root 内(root 本身或其子项)。 - 写操作(
writeText):路径清洗后必须是某个 writable root 的子项(root 本身不可写,与宿主 canonical 写策略一致)。 - 绝对路径(如
/etc/passwd)一律拒绝。 ..路径逃逸(如../outside或output/../../escape)一律拒绝。- 路径中包含 NUL 字符一律拒绝。
exists是例外:路径不在 visible roots 内时返回false而非抛错,方便脚本做条件判断。
Profile 可以自定义基础 visible / writable roots;调用级 policy 还可能进一步投影只读 root。脚本开发者应以实际 invocation 为准。如果脚本需要写入某个目录,确保该目录最终可写。
4.1 写入语义
workspace.writeText 的写入采用最终状态语义:
- 同路径多次写:脚本对同一文件多次调用
writeText时,引擎只保留最后一次的内容。落盘的 delta 是最终状态,而非多次追加。 - 写入冲突检测:引擎在执行前对工作区做文件快照(含 SHA-256)。落盘时,如果文件在快照后被外部修改(SHA-256 不匹配),写入会以
stale冲突报错 fail-fast——不会覆盖外部修改。 - 部分失败语义:批量写入时如果中途某个文件失败,已成功写入的文件不会被回滚,也不会自动提交到聊天——错误消息中包含已写入文件列表与失败文件,调用者需重新读取已写入文件后再重试。
5. 模块导入
脚本支持 ES Module 的 import 语法。模块解析由引擎在内存中完成——脚本不接触物理文件系统。宿主不内嵌任何第三方库:需要的库由 skill 自己随 scripts/ 目录分发,版本完全由 skill 开发者掌控。
5.1 相对导入(./ 或 ../)
相对导入引用当前 Skill scripts/ 目录内的其他模块(含自带的第三方库文件)。执行时,Application 将 skill 的 scripts/**/*.js 全部读取为内存模块快照(逻辑模块名 → 源码字符串),相对导入按 importer 的逻辑模块名规范化解析,且只能命中这张快照中的模块——快照外的模块(含越界 ../ 与裸模块名)解析失败,模块声明/求值报错。
// 导入同目录下的 helper.js
import { format } from './helper.js';
// 导入子目录下的模块
import { parse } from './lib/parser.js';
// 导入自带的第三方库(见 5.2)
import { marked } from './vendor/marked.js';
模块快照受 6.1 资源限制 约束,超过即拒绝执行。
my-skill/
scripts/
main.js ← 入口脚本
helper.js ← 可被 main.js import
lib/
parser.js ← 可被 main.js import (./lib/parser.js)
vendor/
marked.js ← 自带的第三方库 (./vendor/marked.js)
5.2 自带第三方库
沙箱不提供任何内置第三方库,宿主升级也不会改变脚本可用的库版本——需要的库随 skill 分发,skill 开发者自行管理版本与更新。
放置位置:skill 的 scripts/ 下任意子目录(推荐 scripts/vendor/),经相对导入使用。
打包要求:
- 单文件 ESM bundle:把库及其全部依赖打进一个
.js文件(import/export语法保留,不能有残留的外部裸模块导入)。 - 零 Node / 浏览器依赖:不能引用
process、Buffer、fs、DOM 等宿主对象。 - 计入模块快照上限:64 个文件 / 2 MB 总量(见 5.1),带多个大库时注意精简。
推荐用 esbuild 打包:
# 在含 node_modules 的目录下,把 marked 连同依赖打成单文件 ESM
npx esbuild --bundle marked --format=esm --outfile=my-skill/scripts/vendor/marked.js
// my-skill/scripts/main.js
import { marked } from './vendor/marked.js';
export default function (args) {
return { html: marked.parse(args.markdown ?? '') };
}
JSON 处理不需要库:QuickJS 内置
JSON.parse/JSON.stringify。 正则表达式不需要库:QuickJS 内置完整RegExp支持。 打包后建议本地冒烟执行一次,确认无外部依赖残留。
6. 沙箱限制
6.1 资源限制
| 限制项 | 默认值 | 说明 |
|---|---|---|
| 内存上限 | 32 MB | 超限时 QuickJS 自动中断 |
| 栈大小上限 | 256 KB | 超限时 QuickJS 自动中断 |
| 执行超时 | 30 秒 | 超时后通过 interrupt handler 中断(如死循环) |
| 返回值大小上限 | 256 KB(262,144 字节) | 返回值经 JSON.stringify 序列化后超过此大小则 fail-fast |
| 模块快照数量上限 | 64 个 | skill scripts/ 下 .js 文件数超过此上限则拒绝执行 |
| 模块快照字节上限 | 2 MiB(2,097,152 字节) | skill scripts/ 下所有 .js 源码总字节数超过此上限则拒绝执行 |
| 总输入预算 | 8 MiB | 模块源码 + 工作区快照 + args + context 的总字节数,超过直接终止 |
| 总输出预算 | 1 MiB | 最终 delta + 日志 + 返回值的总字节数(每项含少量固定记账成本),超过直接终止 |
| 全局并发上限 | 2 | 多个 Agent / 子 Agent 同时执行脚本时排队 |
返回值经 JavaScript JSON.stringify 序列化后传回宿主。以下值会导致序列化失败并报错:
- 循环引用:
JSON.stringify抛出Converting circular structure to JSONTypeError。 BigInt:JSON.stringify抛出Do not know how to serialize a BigIntTypeError。Symbol/ 函数:作为对象属性值时被丢弃,作为数组元素时变成null;顶层返回时得到undefined并被明确拒绝。- 数组中的
undefined:序列化为null,与标准JSON.stringify一致。 undefined:返回undefined会被明确拒绝——返回null显式表示空值。
超时和返回值超限分别以专用错误传播给 Agent:
- 超时:
skill.run_script_execution_failed,消息包含timed out。 - 返回值超限:
skill.run_script_result_too_large,提示用workspace.writeText将大输出写入 workspace 而非直接返回。
6.2 隔离语义
- 每次执行创建全新的 QuickJS Runtime + Context,不存在跨执行的共享状态。
- 脚本在
spawn_blocking中同步执行,不阻塞 tokio 运行时。 - 没有网络访问能力:不能发起 HTTP 请求、不能使用
fetch/XMLHttpRequest。 - 没有定时器:不能使用
setTimeout/setInterval/setImmediate。 - 没有进程访问能力:不能执行 shell 命令、不能 spawn 子进程。
- 没有 Node / 浏览器内置对象:
process、Buffer、module、require(CommonJS)、window、document等均不可用;脚本只能通过 ES Moduleimport从@tauritavern/runtime/v1导入workspace/context/log访问外部能力。 eval()/new Function()是 QuickJS 的标准语言特性、并未禁用,但它们无法逃逸沙箱(没有 Node 对象、没有网络、文件读写受workspace门禁)。不要依赖它们访问外部资源。
6.3 安全边界汇总
| 能力 | 是否可用 |
|---|---|
| 读 visible roots 内文件 | 是 |
| 写 writable roots 内文件 | 是 |
| 读 visible roots 外文件 | 否(fail-fast) |
绝对路径 / .. 逃逸 |
否(fail-fast) |
| 导入 Skill scripts/ 内的模块(含自带库) | 是(内存快照解析) |
| 导入快照外 / scripts/ 外的模块 | 否(fail-fast) |
| 网络请求 | 否 |
| 修改宿主变量 / 世界书 | 否;context 只是本次执行的副本 |
| 访问 Node / 浏览器内置对象 | 否 |
| 访问进程 / shell | 否 |
7. 完整示例
7.1 数据处理脚本
// skills/data-processor/scripts/process.js
import { context, workspace, log } from '@tauritavern/runtime/v1';
export default function (args) {
const { text, format } = args;
log.info('Processing text');
// 读取配置文件
const config = JSON.parse(workspace.readText('output/config.json'));
// 处理文本
let result = text.trim();
if (format === 'uppercase') {
result = result.toUpperCase();
}
// 分块处理(原生实现,无需第三方库)
const lines = result.split('\n');
const size = config.batchSize ?? 10;
const batches = [];
for (let i = 0; i < lines.length; i += size) {
batches.push(lines.slice(i, i + size));
}
// 写入结果
workspace.writeText('output/result.txt', batches.map(b => b.join('\n')).join('\n---\n'));
// 访问世界书
const loreEntries = context.worldInfo.entries.filter(e => e.constant);
// 读取变量
const counter = context.variables.local.counter ?? '';
return {
processed: result,
batchCount: batches.length,
loreCount: loreEntries.length,
counter,
};
}
7.2 使用多模块与自带库的脚本
// skills/markdown-builder/scripts/main.js
// marked 与 dayjs 由 skill 自带(见 5.2 的 esbuild 打包方式),
// 版本由 skill 开发者自行管理。
import { marked } from './vendor/marked.js';
import dayjs from './vendor/dayjs.js';
import { workspace } from '@tauritavern/runtime/v1';
import { formatHeader } from './format.js';
export default function (args) {
const header = formatHeader(args.title);
const timestamp = dayjs().format('YYYY-MM-DD HH:mm:ss');
const markdown = `# ${header}\n\n_Generated at ${timestamp}_\n\n${args.body}`;
const html = marked.parse(markdown);
workspace.writeText('output/article.html', html);
return {
markdown,
html,
bytes: html.length,
};
}
// skills/markdown-builder/scripts/format.js
export function formatHeader(title) {
return title.trim().replace(/\s+/g, ' ');
}
7.3 SKILL.md 中的脚本说明
在 SKILL.md 中应明确记录每个脚本的参数和返回值,以便模型正确调用:
---
name: data-processor
description: Text processing utilities with configurable format and batching.
---
## Scripts
### process
Processes input text with configurable format and batch size.
**Arguments:**
- `text` (string, required): Input text to process.
- `format` (string, optional): `"uppercase"` to uppercase the text.
**Returns:**
- `processed` (string): The processed text.
- `batchCount` (number): Number of batches generated.
- `loreCount` (number): Number of constant world info entries.
- `counter` (string): Value of the local "counter" variable.
**Side effects:**
- Reads `output/config.json`.
- Writes `output/result.txt`.
8. 最小约定
- 使用 ES module,并导出
default(args)或main(args)。 - 返回 JSON 可序列化的小结果;大内容写入 workspace。
- workspace 路径使用当前 policy 允许的相对路径。
context是隔离快照,不会把修改写回宿主。- 第三方依赖随 Skill 打包,不依赖 Node、浏览器或网络 API。
- 在
SKILL.md中写清脚本参数、返回值和文件副作用。