mastra-virtual-fs
v1.0.3
Published
Mastra Workspace 的内存虚拟文件系统:把 skill 的 SKILL.md / references 以字符串直接喂给 agent,不落盘。
Maintainers
Readme
mastra-virtual-fs
Mastra Workspace 的内存虚拟文件系统 —— 把 skill 的
SKILL.md和references/以字符串直接喂给 agent,不落盘。
Mastra 的 Workspace 默认只带 LocalFilesystem(读本机磁盘)。很多时候你想把 skill 内容
动态地以字符串传进去(从数据库、远程、运行时拼装),而不是先写到磁盘。MastraVirtualFileSystem
把数据全部存在内存里,完整实现了 Mastra 的 WorkspaceFilesystem 接口,因此 workspace.skills
能像读本地 skill 一样读到它们;也可单独当作通用内存 FS 使用。
特性
- 📦 零落盘 —— skill / reference 以字符串 seed,内容只在内存。
- 🔌 即插即用 —— 实现完整
WorkspaceFilesystem接口,直接传给new Workspace({ filesystem })。 - 🧠 对 agent 健壮 —— 内置
looseReferenceLookup,兼容弱模型把 reference 路径"挂错根"的情况(见下)。 - 🧰 通用内存 FS —— 读写、目录、
copy/move、只读模式,错误用 Node 风格err.code。 - 🟦 TypeScript 优先 —— 自带类型声明,ESM。
安装
npm install mastra-virtual-fs @mastra/core
# 或 pnpm add / yarn add@mastra/core 是 peer dependency,由你的项目提供(>=1.25.0)。要求 Node ≥ 18,ESM。
快速上手
跑一个真实 Agent(最简完整 demo)
下面是一个可直接运行的端到端示例:把一个「天气播报」skill(SKILL.md + 一个 reference)
以字符串 seed 进内存,挂到真实 Agent 上;agent 会自动激活 skill、读取 reference,再按
skill 规定的格式回答 —— 全程不落盘。
import 'dotenv/config';
import { Agent } from '@mastra/core/agent';
import { Workspace } from '@mastra/core/workspace';
import { MastraVirtualFileSystem } from 'mastra-virtual-fs';
// 1) skill 内容:SKILL.md(带 YAML frontmatter)+ references,全是字符串
const SKILL_MD = `---
name: weather-reporter
description: 天气播报 skill。当用户询问城市天气、气温或穿衣建议时使用,按规定格式输出。
---
# Weather Reporter
播报天气时,严格按三行输出:
1. \`【<城市>】天气:<晴/多云/雨…>\`
2. \`气温:<最低>~<最高>℃\`
3. \`建议:<一句话穿衣建议>\`
穿衣建议必须依据 \`references/穿衣指南.md\`。
`;
const REFERENCES = {
'穿衣指南.md': `# 穿衣指南(按最高气温)
- ≥ 28℃:短袖短裤,注意防晒
- 18~27℃:长袖或薄外套
- 10~17℃:外套 + 长裤
- < 10℃:厚外套保暖
`,
};
// 2) 内存 FS:把 skill + references seed 进去(不落盘)
const filesystem = new MastraVirtualFileSystem();
filesystem.seedSkill('/skills/weather-reporter', SKILL_MD, REFERENCES);
// 3) 用这个 FS 建 Workspace 并挂上 skill
const workspace = new Workspace({
filesystem,
skills: ['/skills/weather-reporter'], // 绝对路径最稳妥
});
// 4) 建 Agent,把 workspace 挂上去 —— Mastra 会自动把 skill 工具注入 agent
const agent = new Agent({
id: 'weather-agent',
name: 'weather-agent',
instructions: '你是天气播报助手。回答天气相关问题时,激活并遵循 weather-reporter skill。',
model: process.env.MODEL, // 需配对应 provider 的 key
workspace,
});
// 5) 对话:agent 自动激活 skill、读取 references/穿衣指南.md,再按格式播报
const res = await agent.generate('南京今天最高气温 32℃,最低 26℃,晴。帮我播报天气。', {
maxSteps: 12,
});
console.log(res.text);运行(需要一个模型 API key;MODEL 用 Mastra 的 provider/model magic string):
export MODEL=openrouter/openai/gpt-5.5
export OPENROUTER_API_KEY=sk-or-... # 换 provider 就配对应的 key,如 OPENAI_API_KEY
npx tsx demo.ts # 文件含顶层 await,以 ESM 运行预期输出大致如下(内容来自内存里的 skill,穿衣建议来自 reference):
【南京】天气:晴
气温:26~32℃
建议:短袖短裤,注意防晒API
new MastraVirtualFileSystem(options?)
| 选项 | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| id | string | 自动生成 | 实例 id |
| readOnly | boolean | false | 为 true 时所有写操作抛 EACCES(agent 不会拿到写类工具) |
| debug | boolean | false | 把每一次底层 fs 调用打到 console.error,便于调试路径解析 |
| seed | Record<string, FileContent> | — | 用 { 路径: 内容 } 预置初始文件 |
| looseReferenceLookup | boolean | true | 读路径未命中且看似"挂错根"时,按唯一后缀匹配兜底到真实文件(见下) |
| directoryMtimeFollowsContents | boolean | true | 目录 modifiedAt 跟随其下文件内容的最新修改时间,让 reseed SKILL.md 后下一次 generate 默认能热更新(见下) |
便捷方法
| 方法 | 说明 |
| --- | --- |
| seedFile(path, content, mimeType?): this | 写入单个虚拟文件(自动建父目录),返回 this 可链式调用 |
| seedSkill(skillDir, skillMd, references?): this | 一步 seed 一个 skill(SKILL.md + references/*) |
| listAllPaths(): string[] | 调试用,导出当前所有文件路径 |
WorkspaceFilesystem 方法
标准接口方法,均 async:readFile / writeFile / appendFile / deleteFile /
copyFile / moveFile / mkdir / rmdir / readdir / exists / stat / realpath,
以及 getInfo / isReady / init / destroy 等生命周期方法。
错误以 Node 风格 err.code 抛出:ENOENT、EISDIR、ENOTDIR、EEXIST、ENOTEMPTY、
EACCES(只读)。消费方应按 err.code 判断,而非 instanceof。
looseReferenceLookup(默认开)
agent 用通用文件工具(file_stat / read_file)读 skill reference 时,常只给相对路径
references/x.md;它会被 Mastra 的挂载层规整成根下的 /references/x.md,而真实文件其实在
/skills/<skill>/references/x.md —— 于是读取 ENOENT、agent 拿不到内容。由于你无法控制终端
用户的 prompt 和模型,这个兜底默认开启:读未命中时,若该路径的父目录并不真实存在,就在所有
文件里找唯一一个以该路径结尾的真实文件并命中。
安全约束(不偏离 LocalFilesystem 语义):
- 只作用于读(
readFile/stat/exists),不动写操作; - 仅当被读路径的父目录不真实存在时才兜底(父目录存在 = 只是文件缺失,照常
ENOENT); - 后缀匹配必须唯一,歧义(多个 skill 同名引用)则不兜底。
因此 getReference(skill, 'x.md')(缺 references/ 前缀,落在已存在的 skill 目录下)仍按契约
返回 null,与真实 FS 一致。debug: true 时兜底命中会打印 readFile→loose / stat→loose。
需要与真实磁盘 FS 完全一致的严格行为时,置 looseReferenceLookup: false。
directoryMtimeFollowsContents(默认开)
Mastra 的 skills 系统会缓存 SKILL.md(name / description / instructions)。agent.generate()
在第一步会调 workspace.skills.maybeRefresh(),它靠比较「skill 目录的 mtime」和「上次发现时间」
来决定要不要重新发现 skill。本项默认开启:reseed(重写)SKILL.md 会把它所在目录及各级父目录的
modifiedAt 顶到当下,于是下一次 generate 默认就能读到最新的 SKILL.md——无需手动 refresh()
或 checkSkillFileMtime。references 本来就每次现读(getReference 直接走 readFile),始终最新。
⚠️ 时序:陈旧检查有 ~2s 冷却,且按「严格大于」比较 mtime。真实
agent.generate()第一轮本就 耗时数秒,天然满足;在同一毫秒内连续 reseed 的极端情况可能漏判。
这是相对真实磁盘的有意偏离:POSIX / LocalFilesystem 里,改写已存在文件的内容不会改变其父
目录 mtime(只有新增/删除/改名条目才会)。需要与磁盘 FS 严格一致时,置
directoryMtimeFollowsContents: false——届时目录 mtime 只在结构变化(新增/删除文件、mkdir /
rmdir)时更新;此时想热更新 SKILL.md 内容,可用 workspace.skills.refresh(),或建 Workspace 时传
checkSkillFileMtime: true。
skill 缓存与刷新时机(重要)
「改了 skill,agent 什么时候才读到最新?」这取决于 Mastra 上游的 skills 缓存机制(与用哪个
filesystem 无关,LocalFilesystem 行为一致),分 SKILL.md 与 reference 两种情况。核心规则:
- 发现 + 缓存是懒加载的:
new Workspace()/new Agent()不读盘;直到第一次generate()的第 0 步(processInputStep调skills.maybeRefresh())才发现 skill 并缓存name/description/instructions。 - 同一次
generate()内只在第 0 步刷新:后续 step 全程吃这份缓存(maybeRefresh被写死stepNumber === 0;load_skill还会把 instructions 冻进该轮的 thread state)。 - reference 不缓存:
getReference()每次都现读filesystem.readFile(),随时最新。
心智模型:
new Workspace / new Agent → 什么都不读,无缓存
第一次 generate 的 step0 → 发现 + 缓存 SKILL.md ← 缓存就发生在这一刻(不是 new Agent 时)
同一次 generate 的 step 1..N → 全程吃缓存(此时改 SKILL.md 当轮不生效)
第二次 generate 的 step0 → 重新判断陈旧;陈旧则重新发现 → 读到最新 SKILL.md| 改动发生的时机 | SKILL.md(instructions/description) | reference |
| --- | --- | --- |
| new Agent() 之后、首个 generate() 之前 | ✅ 首个 generate 读到最新(懒加载) | ✅ 最新 |
| 某次 generate() 跑到一半(后续 step) | ❌ 当轮不变(第 0 步已定格) | ✅ 改之后被读到就拿到最新(现读) |
| 两次 generate() 之间 | ✅ 下一次 generate 读到最新(靠 directoryMtimeFollowsContents,见上) | ✅ 最新 |
实用推论:只要把修改放在目标 generate() 启动之前(哪怕在 new Agent() 之后),那次 generate
就会用上最新 SKILL.md。唯一抓不住的是「已经跑起来的那一次 generate 的后续 step」——这是 Mastra 的
stepNumber === 0 限制,FS 这层改不了(真要的话只能在 app 层用 onStepFinish 等钩子手动
workspace.skills.refresh(),一般没必要)。
关键契约 / 易踩的坑
以下均已与真实
LocalFilesystem逐项对照验证。
- Mastra 的 skills 完全通过 workspace 的 filesystem 读取,所以只要实现
WorkspaceFilesystem接口,skills 就能工作。 - skill 目录结构:
<skillDir>/SKILL.md+<skillDir>/references/<相对路径>, 目录名是references(复数)。 getReference(skillName, refPath)的refPath必须带references/前缀 (如'references/方法.md'、'references/sub/嵌套.md'),不带前缀返回null。- 递归
readdir的name是相对子路径(嵌套文件为'sub/nested.md'而非 basename), 否则嵌套引用会丢失。 - 路径统一规整为绝对 POSIX 路径(
./../ 尾斜杠均归一);in-memory FS 的basePath为undefined。
示例与开发
完整的可运行示例(端到端 skills demo、与 LocalFilesystem 的对照 probe、挂到真实 Agent 的
多 provider demo)在仓库的 apps/test 里。克隆仓库后:
pnpm install
pnpm test # 离线 smoke 测试(单元 + Workspace 集成)
pnpm demo # seed skill → 通过 workspace.skills 读取
pnpm agent # 挂到真实 Agent(需在 apps/test/.env 填任一 provider 的 key)
pnpm mutation # 离线断言:reseed skill 后的缓存/刷新行为(默认 vs 严格模式)
pnpm change-skill # 真实 Agent:在 generate 执行【过程中】改 skill,看读取轨迹(需 key)License
MIT
