TauriTavern/docs/Agent/SkillScript.md

19 KiB
Raw Blame History

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 JSON TypeError。
  • BigInt:JSON.stringify 抛出 Do not know how to serialize a BigInt TypeError。
  • 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 Module import 从 @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. 最小约定

  1. 使用 ES module,并导出 default(args) 或 main(args)。
  2. 返回 JSON 可序列化的小结果;大内容写入 workspace。
  3. workspace 路径使用当前 policy 允许的相对路径。
  4. context 是隔离快照,不会把修改写回宿主。
  5. 第三方依赖随 Skill 打包,不依赖 Node、浏览器或网络 API。
  6. 在 SKILL.md 中写清脚本参数、返回值和文件副作用。