Directus AI Tools 之 Folders 工具:让 Agent 安全地对 Directus 文件目录做 CRUD 的实现解析
本篇围绕 Directus 仓库中 AI 工具集的 folders 提示词文档(api/src/ai/tools/folders/prompt.md)展开,完整继承该文档定义的四类操作(create / read / update / delete)、关键注意事项与常见错误,并结合其背后的工具定义、Zod 输入/校验 Schema 与 FoldersService 源码,讲清这个"文件夹 CRUD 工具"如何被 AI Agent 调用、如何被权限体系约束,以及写自定义 Directus 工具或对接 MCP 时可以直接复用的设计模式。
一、这份 prompt.md 是什么:AI 工具说明书的"说明书"
在 Directus 的 AI 工具目录 api/src/ai/tools/ 下,每个工具都由三部分组成:
prompt.md:写给 LLM 的自然语言指令,说明"这个工具能做什么、怎么调用、有哪些坑";index.ts:工具的 TypeScript 定义,声明输入/输出 Schema 与真正的执行逻辑(handler);index.test.ts:对 handler 各分支行为的单元测试。
folders 工具的提示词正是 prompt.md。它在运行时会被读进工具定义中——在 folders/index.ts 里可以看到这一行:
instructions: requireText(resolve(__dirname, './prompt.md')),
也就是说,prompt.md 不是给人看的文档,而是随工具一起注入给模型的 instructions:模型决定调用 folders 工具时,这份说明就构成它的"操作手册"。这也是理解本文所有内容的视角——文档中的每一句话,最终都要在 index.ts 的 Schema 校验与 handler 逻辑里兑现。
工具的整体注册位置在 ai/tools/index.ts 的 ALL_TOOLS 数组中,folders 与 files、items、assets 等并列,构成 Directus 内置 AI 能力(AI Chat / MCP)可用的工具集。
二、继承自原文档:Folders 工具的能力面与调用格式
2.1 工具定位
原文档第一句即给出定位:
Perform CRUD operations on Directus Folders. Folders are used to organize files in Directus.
对应在源码中,工具注册时的 description 与检索 keywords 分别是(见 folders/index.ts):
description:
'Reads and changes Directus file folders. Use to organize files into folder hierarchies or inspect existing folder records.',
keywords: ['directories', 'media folders', 'file organization', 'parent folder'],
keywords 用于帮助路由/检索逻辑把用户意图("整理媒体目录"、"建个父文件夹")匹配到该工具;annotations.destructiveHint: true 则显式标记本工具包含破坏性操作(delete),供上层决定是否做二次确认。
2.2 四个可用动作
原文档列出的 Available Actions 与源码中 FoldersValidateSchema(一个按 action 字段区分的 discriminated union,见 folders/index.ts)一一对应:
| action | 原文档说明 | 源码中的必填/可选参数(Zod 定义) |
|---|---|---|
create |
Add new folders records | data 必填:单个 folder 对象或数组;query 可选 |
read |
List/query metadata or get specific items by ID | keys 可选(主键数组);query 可选 |
update |
Modify existing metadata | data 必填(单个对象,批量时为数组走 batch 分支);keys 可选;query 可选 |
delete |
Remove folders by keys | keys 必填,且必须是数组 |
注意 z.strictObject 的用法:校验层使用严格对象,未声明的字段会直接拒绝,这保证了模型传入多余字段时会被拦截而不是静默忽略。
2.3 创建文件夹的调用示例
原文档给出的标准调用格式如下:
{
"action": "create",
"data": {
"name": "Product Images",
"parent": "parent-folder-uuid"
}
}
其中 data 的对象结构由 ai/tools/schema.ts 中的 FolderItemInputSchema 定义:
export const FolderItemInputSchema = z.object({
id: PrimaryKeyInputSchema.optional(), // 可选,显式指定主键
name: z.string(), // 必填,文件夹名称
parent: z.string().optional(), // 可选,父文件夹 UUID
});
结合 folders/index.ts 的 create 分支,完整调用链是:
buildSanitizedQueryFromArgs先对可选的query做权限化清洗(fields缺省为'*');toArray(args.data)把单个对象或数组统一成数组,交给service.createMany(data);- 再用返回的
savedKeys调service.readMany(savedKeys, sanitizedQuery)读回刚创建的记录返回给模型。
也就是说 create 的返回值不是主键,而是完整的文件夹记录(或在无读权限时为 null)——这一点在测试 folders/index.test.ts 中被精确验证:createMany 返回 ['folder-1'],而工具最终输出的是 [{ id: 'folder-1', name: 'test-folder' }]。
三、逐动作拆解:handler 的分支逻辑与返回约定
folders 工具的 handler(folders/index.ts)按 action 分四支,全部委托给 FoldersService。逐一拆解如下。
3.1 read:keys 优先,否则按 query 查
if (args.keys) {
result = await service.readMany(args.keys, sanitizedQuery);
} else {
result = await service.readByQuery(sanitizedQuery);
}
- 传了
keys就走主键精确读取(readMany); - 不传
keys就退化为按query全量/条件查询(readByQuery),此时query支持 Directus 标准查询参数(filter、sort、limit、fields 等),经sanitizeQuery清洗后执行。
readOnly 属性也被显式声明为 input.action === 'read',上层可据此对 read 动作放宽确认策略,而 create/update/delete 则会被视为写操作。
3.2 update:三种定位路径,按参数形态自动切换
update 分支是四个动作里最有信息量的一个(folders/index.ts):
if (Array.isArray(args.data)) {
updatedKeys = await service.updateBatch(args.data); // ① data 是数组 → 逐条批量更新
} else if (args.keys) {
updatedKeys = await service.updateMany(args.keys, args.data); // ② 有 keys → 用同一份 data 更新多条
} else {
updatedKeys = await service.updateByQuery(sanitizedQuery, args.data); // ③ 都没有 → 按 query 条件更新
}
三个分支的触发条件互斥且可预测:
| 传参形态 | 调用的服务方法 | 适用场景 |
|---|---|---|
data 为数组(每项带 id) |
updateBatch |
一次改多个不同字段/不同目标 |
data 为对象 + keys |
updateMany |
同一份改动应用到多个文件夹 |
data 为对象,无 keys,配 query |
updateByQuery |
条件式更新,如"把所有 name 含 X 的文件夹改名" |
更新完成后同样会 readMany(updatedKeys, sanitizedQuery) 读回结果。测试文件 folders/index.test.ts 对这三条分支各写了一个用例,并断言"只命中目标分支、其余分支方法未被调用",是核对分支边界最可靠的依据。
3.3 delete:必须提供 keys 数组
delete 分支最简:
const deletedKeys = await service.deleteMany(args.keys);
且 FoldersValidateSchema 中 delete 的 keys 是必填的 z.array(PrimaryKeyValidateSchema)(folders/index.ts)——这正是原文档"Important Notes"与"常见错误"反复强调"keys 必须是数组"的原因:Schema 层就不接受字符串。返回值是被删除的主键列表(见 测试用例)。
此外 handler 末尾有兜底:
throw new Error('Invalid action.');
由于 TypeScript 类型收窄,理论上不可达,但测试仍显式验证了它对非法 action 的拒绝行为(测试)。
四、原文档 Important Notes 的源码级验证
原文档有三条 Important Notes,逐条都能在源码中找到对应证据。
4.1 "Folders are virtual":目录不存在于存储适配器
Folders are not mirrored with the storage adaptor, only stored in the database.
从源码结构看,FoldersService 继承自 ItemsService<Folder> 并固定绑定系统集合 directus_folders(services/folders.ts):
export class FoldersService extends ItemsService<Folder> {
constructor(options: AbstractServiceOptions) {
super('directus_folders', options);
}
}
它的所有读写都是数据库层面的 CRUD,与 StorageDriver(S3/GCS/本地盘等)完全解耦。对使用者的实际含义:删除/移动文件夹不会触碰底层文件,文件对象的 folder 外键关系由数据层维护。
4.2 "Permissions":访问控制由 accountability 贯穿
工具 handler 拿到调用上下文后,把 schema 与 accountability 原样注入服务:
const service = new FoldersService({ schema, accountability });
随后每一个 readByQuery/readMany/updateByQuery 等调用都在该 accountability 下执行;buildSanitizedQueryFromArgs 内部的 sanitizeQuery 也会基于 schema 与 accountability 过滤模型可能越权请求的字段(ai/tools/utils.ts)。因此原文档那句"If you don't see something that the user says you should have access to, it could be a permissions issue"不是空话——create 后 readMany 读不回来、返回 null,就是权限过滤的典型表现(边缘用例 should handle null result from readMany after create 专门覆盖了这一情形)。
补充一个旁证:FoldersService.buildTree 中对非 admin 用户会先对根目录做 validateAccess 的 read 权限检查,再逐层构建目录树(services/folders.ts),说明整个 folders 域对权限约束是一致的。
4.3 "Folder Hierarchy":删除受父子层级约束
Deleting a folder requires it to be empty or will cascade based on settings
文件夹树由 parent 字段(指向另一条 directus_folders 记录的 UUID)构成。buildTree 中可见 parent → children 的索引构建逻辑(services/folders.ts)。AI 工具侧没有暴露级联策略参数,因此对 Agent 的实际建议就是原文档给的:删除前先确认目录为空,层级冲突与级联行为交由数据库层/权限层裁决,工具只负责透传错误。
五、原文档 Mistakes to Avoid:两条易错点对照 Schema
原文档末尾列出两条常见错误,它们恰好都能映射到 Schema 定义上。
-
keys期望数组,即使只有一项。FoldersValidateSchema与FoldersInputSchema中 keys 一律声明为z.array(PrimaryKeyValidateSchema)(folders/index.ts 对应的定义见 folders/index.ts)。传字符串"folder-1"会在校验阶段失败,正确写法是["folder-1"]。 -
Tags 必须是数组:
["tag1", "tag2"]而非"tag1, tag2"。 这条针对的是模型把列表"压扁成逗号分隔字符串"的常见幻觉。从源码结构看,FolderItemInputSchema当前只声明了id/name/parent三个字段,name为必填字符串(ai/tools/schema.ts);原文档 update 说明中提到的 title/description/tags 等元数据字段属于该集合在库层面的完整字段面,若启用则必须遵守"数组即数组"的 JSON 形态,Zod 校验层对错误形态会直接拒收。
另外值得注意一条文档未明说但源码保证的健壮性:模型经常把 data/keys/query 传成字符串化的 JSON,工具框架提供了 coerceJsonFields 在验证前把这些字段尝试 parseJSON 还原(ai/tools/utils.ts),降低了一次因序列化形态错误导致的整次调用失败。
六、输入/输出 Schema 与工具元信息速查
把散落在各处的定义汇总成一张速查表,便于二次开发或对接时引用:
| 项 | 定义位置 | 内容 |
|---|---|---|
| 工具名 | folders/index.ts | folders |
| 输入 Schema | folders/index.ts | action 枚举 + 可选 query / keys 数组 / data 数组 |
| 校验 Schema | folders/index.ts | 按 action 的 discriminated union,strictObject 严格模式 |
| 输出 Schema | folders/index.ts | data 为 folder 记录数组、主键数组或 null |
| 只读判定 | folders/index.ts | action === 'read' 时为只读 |
| 破坏性标记 | folders/index.ts | destructiveHint: true |
| 底层服务 | services/folders.ts | FoldersService extends ItemsService<Folder>,绑定 directus_folders 集合 |
| 工具注册 | ai/tools/index.ts | ALL_TOOLS 数组第 5 项 |
七、测试即契约:用 index.test.ts 校验行为
folders/index.test.ts 通过 vi.mock 替换 FoldersService,把 handler 的每种分支固化为可回归的断言,覆盖点包括:
- create 单条/多条,验证
createMany收到的始终是数组([folderData]),且随后用返回主键调readMany(L42-L92); - read 的 keys / query 两条互斥路径(L94-L140);
- update 的 batch / keys / query 三条互斥路径(L142-L217);
- delete 透传
keys并返回被删主键(L219-L241); - 非法 action 抛错、服务异常透传、create 后读回为
null的降级输出(L244-L309)。
阅读这份测试是理解 prompt.md 中每条约定"到底由谁保证"的最快路径:凡是文档承诺的行为,都能在对应断言中找到落点。
八、实践要点小结
- 调用格式:
action四选一;keys恒为数组;create的data可单对象可数组;delete必须带keys。 - 返回值约定:create/update 返回读回的完整记录,delete 返回被删主键,无读权限时可能为
null。 - 权限心智:一切读写都发生在当前
accountability之下,"查不到"优先怀疑权限而非数据不存在。 - 存储心智:文件夹是纯数据库概念,不随存储适配器镜像,操作文件夹不影响真实文件。
- 扩展心智:若你要为 Directus 新增自定义工具,
prompt.md + defineTool + discriminatedUnion 校验 + Service 委托 + 分支化测试这一整套 folders 工具 的实现模式可以直接照搬。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00