eve 中文教程 · 第 21 章 discover:文件系统怎么变成 manifest
一句话结论:文件名不是「约定」,是一张精确的分类表加一组正则。文件没被识别,九成是撞了表里的某一条——而且失败方式是静默的。
两个让人抓狂的瞬间
第一个:你在 agent/tools/ 下新建了 read-logs.ts,写得好好的。跑 eve dev,问 agent「你有 read_logs 工具吗」,它说没有。文件明明在那儿。
第二个:你想给 agent 加点背景资料,建了个 agent/prompts/ 目录,放了几个 md 文件。完全没反应,连个警告都没有。
这两件事的答案都在同一个地方:eve 启动时会扫一遍 agent/ 目录,但只认它认识的目录名和文件名。不认识的东西不会报错,只会被忽略。
第 3 章讲过「什么该放哪」。这一章讲那些规则在代码里长什么样,以及它们怎么失败。搞懂之后,上面两个问题你会一眼看出原因。
21.1 扫描器长什么样
整个扫描的入口只有三个输入:
// packages/eve/src/discover/discover-agent.ts
export async function discoverAgent(input: DiscoverAgentInput): Promise<DiscoverAgentResult> {
// source 可替换:磁盘、内存、测试桩实现同一个接口
const source = input.source ?? createDiskProjectSource();
// 两个根先 resolve:后面所有 logicalPath 都相对它们计算
const appRoot = resolve(input.appRoot);
const agentRoot = resolve(input.agentRoot);
// role 决定用哪套槽位规则:agent 还是 subagent
const role = input.role ?? "agent";
// ...
}
agentRoot、appRoot、role。role 是 "agent" 或 "extension"——扩展的源树用同一套扫描器,但禁用一部分槽位。
输出是 { manifest, diagnostics }。manifest 是一份纯数据的清单:
// packages/eve/src/discover/manifest.ts
// 一个槽位一个数组:槽位为空就是空数组,下游不用判 undefined
export interface AgentSourceManifest {
agentId: string;
agentRoot: string;
appRoot: string;
channels: ChannelSourceRef[];
connections: ConnectionSourceRef[];
configModule?: ModuleSourceRef;
extensions: ExtensionSourceRef[];
hooks: ModuleSourceRef[];
instructions: InstructionsSourceRef[];
lib: LibSourceRef[];
memories: MemorySourceRef[];
sandbox: SandboxSourceRef | null;
schedules: ScheduleSourceRef[];
skills: SkillSourceRef[];
subagents: SubagentSourceRef[];
tools: ModuleSourceRef[];
// 只有计数,明细在 .eve/discovery/diagnostics.json
diagnosticsSummary: { errors: number; warnings: number };
// kind + version 是产物契约:读取方先校验这两个字段
kind: "eve-agent-discovery-manifest";
version: 15;
}
注意这里没有 eve:defaults 里的 bash、read_file、write_file。discovery 只记录作者写的东西。框架默认能力在编译阶段才注入,那是第 22 章的事。
所以「扫描」这件事本身只做一件事:把目录里的文件,变成上面这张表里的一行。下面五个问题,就是它做判断的全部依据。
21.2 第一个问题:哪些文件算数
所有判断集中在一个函数里,一张巨大的 if 表:
// packages/eve/src/discover/filesystem.ts:145
export function classifyAgentRootEntry(
name: string,
entryType: DirectoryEntryType,
): AgentRootEntryKind {
if (entryType === "file") {
// 模块槽位:agent.ts / agent.js / agent.mts 都算,比的是去掉扩展名后的基名
if (matchesSupportedModuleBaseName(name, "agent")) {
return "agent-config-module";
}
// markdown 槽位用 toLowerCase 比较:INSTRUCTIONS.MD 也识别(历史兼容)
if (name.toLowerCase() === "instructions.md") {
return "instructions-markdown";
}
// ... 其余文件槽位
return "unknown";
}
if (entryType === "directory") {
// .eve、node_modules 这类生成目录先排除,避免递归扫进去
if (GENERATED_AGENT_DIRECTORY_NAMES.has(name)) {
return "ignored-directory";
}
// 目录槽位是精确匹配:大小写错了就落到 unknown
if (name === "channels") {
return "channels-directory";
}
// ... 其余目录槽位,完整列表见下表
}
return "unknown";
}
展开成完整清单:
| 条目 | 类型 | 识别为 |
|---|---|---|
agent.{ts,js,cts,mts,cjs,mjs} |
文件 | 根配置模块 |
instructions.md |
文件 | 指令(markdown) |
instructions.{ts,...} |
文件 | 指令(模块) |
instructions/ |
目录 | 多条指令,文件名排序 |
memory.{ts,...} / memory/ |
文件/目录 | 记忆 provider |
system.md / system.{ts,...} |
文件 | 旧版指令,已废弃 |
tools/ channels/ connections/ hooks/ schedules/ skills/ subagents/ sandbox/ extensions/ |
目录 | 各自的能力槽位 |
lib/ |
目录 | 共享代码,不算能力 |
instrumentation/ |
目录 | 追踪埋点 |
.devtools/ .eve/ .next/ .output/ .vercel/ node_modules/ |
目录 | 静默忽略 |
不在表里的目录不会报错,只会生成一条 warning:
Ignoring unsupported directory "foo/" in the agent root.
这条 warning 默认不显示——reportDiscoverWarnings 只在编译时 console.warn,而 eve dev 的启动输出会把它刷过去。所以「我建了个 prompts/ 目录,为什么没生效」的答案永远是:它被忽略了,没有任何东西读它。
一处大小写不一致
instructions.md 和 system.md 用 toLowerCase() 比较,agent.ts、memory.ts 用精确匹配:
| 文件名 | 是否识别 |
|---|---|
instructions.md / INSTRUCTIONS.MD / Instructions.Md |
都识别 |
agent.ts |
识别 |
Agent.ts / AGENT.ts |
不识别,落到 unknown |
这不是笔误,是历史遗留:markdown 槽位早期为了兼容 Windows 大小写不敏感的文件系统放宽了比较,模块槽位没跟着改。写文件名时统一用小写,能绕开这个不一致。
21.3 第二个问题:哪些扩展名算模块
// packages/eve/src/discover/filesystem.ts:7
// 六种扩展名都算「作者写的模块」。.d.ts 不在里面,由 isTypeScriptDeclarationFileName 单独排除
export const SUPPORTED_AUTHORED_MODULE_FILE_EXTENSIONS = [
".cts",
".mts",
".cjs",
".mjs",
".ts",
".js",
] as const;
六种扩展名。注意 .tsx 和 .jsx 不在里面——eve 的 agent 目录不处理 JSX,通道的 UI 在别处(第 17 章的前端)。
光有扩展名还不够,还有一套排除规则:
// .d.ts 只是类型声明,不是可加载模块:先排除,否则 tools/foo.d.ts 会被当成工具
export function getSupportedModuleBaseName(name: string): string | null {
if (isTypeScriptDeclarationFileName(name)) return null; // .d.ts / .d.cts / .d.mts
// 按后缀剥掉扩展名,剩下的就是槽位名
for (const extension of SUPPORTED_AUTHORED_MODULE_FILE_EXTENSIONS) {
if (name.endsWith(extension) && name.length > extension.length) {
return name.slice(0, -extension.length);
}
}
return null;
}
// 测试文件不参与发现:__tests__/ 目录和 *.test.ts / *.spec.ts 都跳过
export function isAuthoredTestPath(path: string): boolean {
return /(?:^|[/\\])__tests__(?:[/\\]|$)/.test(path) || /\.(?:test|spec)\.[cm]?[jt]s$/.test(path);
}
三类文件被跳过:
.d.ts:类型声明不是实现。想在tools/里放类型定义可以,但别指望它变成工具。*.test.ts/*.spec.ts/__tests__/:测试文件不进 agent。*.map:sourcemap 忽略。
最后一条有个细节:isGeneratedSourceMapFileName 会检查 .map 前面的文件名是不是合法模块或声明,所以 tools/foo.ts.map 被忽略,但 tools/foo.map 不会——后者会被当成一个未识别文件。
21.4 第三个问题:目录怎么往里走
所有 xxx/ 目录都走同一个 walker:
// packages/eve/src/discover/named-source-directory.ts:109
// 省略了两个重载签名,只留实现
export async function discoverNamedSourceDirectory(input): Promise<{
diagnostics: DiscoverDiagnostic[];
sources: (MarkdownSourceRef<TDefinition> | ModuleSourceRef)[];
}> {
const directoryPath = join(input.rootPath, input.directoryName);
// 用已经读到的目录项查,不额外 stat:一次扫描只做一轮磁盘 IO
const directoryEntry = input.rootEntries.find((entry) => entry.name === input.directoryName);
// 目录不存在不是错误:槽位本来就是可选的
if (directoryEntry === undefined) {
return { diagnostics: [], sources: [] };
}
// 存在但不是目录:报 error 诊断,这个槽位整体作废
if (!directoryEntry.isDirectory()) {
return {
diagnostics: [
createDiscoverErrorDiagnostic({
code: input.invalidDirectoryCode,
message: input.invalidDirectoryMessage,
sourcePath: directoryPath,
}),
],
sources: [],
};
}
// ... 递归或平铺遍历
}
行为由三个参数决定:recursive、allowMarkdown、validateSegment。
| 槽位 | recursive | markdown | 名字校验 |
|---|---|---|---|
tools/ |
是 | 否 | TOOL_SLUG_PATTERN |
channels/ |
是 | 否 | CHANNEL_SLUG_PATTERN |
hooks/ |
是 | 否 | HOOK_SLUG_PATTERN |
extensions/ |
否 | 否 | EXTENSION_SLUG_PATTERN |
lib/ |
是 | 否 | 无 |
instructions/ |
否 | 是 | 无 |
schedules/ |
是 | 是 | 无 |
connections/ |
是 | 否 | CONNECTION_SLUG_PATTERN |
tools/ 是递归的,这一点常被误解。named-source-directory.ts 的注释里写「Used by the tools/ (flat)」,但调用点传的是 recursive: true:
// packages/eve/src/discover/discover-agent.ts
const toolsResult = await discoverNamedSourceDirectory({
directoryName: "tools",
invalidDirectoryCode: DISCOVER_TOOLS_DIRECTORY_INVALID,
// tools/ 递归:子目录会压平成工具名的一部分(上面注释写的是 flat,以这个调用为准)
recursive: true,
rootEntries,
rootPath: agentRoot,
source,
// 每一段路径都过命名校验,非法名字在这里变成诊断,而不是留到编译期
validateSegment: createToolNameDiagnostic,
});
注释过期了,调用点是事实。这是第 19 章那条「不信注释信调用点」的现成案例。
递归带来一个后果:tools/ 下可以建子目录,但每个路径段都要过 createToolNameDiagnostic 的校验。tools/redis/overview.ts 合法,tools/redis cache/overview.ts 不合法——redis cache 里的空格不在允许字符里。
21.5 第四个问题:名字怎么算
文件名去掉扩展名就是 slot 名:
// packages/eve/src/discover/grammar.ts
// 工具名规则:字母开头,只允许字母、数字、下划线、连字符,最长 64
export const TOOL_SLUG_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
规则:字母开头,后面是字母、数字、下划线、连字符,总长不超过 64。read-logs.ts 合法,read logs.ts、1read.ts、read.logs.ts 都不合法——最后一个会得到 read.logs 这个名字,点号不在允许字符里。
递归目录下,名字用连字符压平:
// packages/eve/src/compiler/normalize-tool.ts:53
// logicalPath 是相对 agent/ 的路径,比如 tools/redis/overview.ts
const toolName = stripLogicalPathExtension(source.logicalPath)
.replace(/^tools\//, "") // 去掉槽位前缀
.replaceAll("/", "-"); // 子目录压平成连字符:redis-overview
所以:
| 文件 | logicalPath | 工具名 |
|---|---|---|
tools/read_logs.ts |
tools/read_logs.ts |
read_logs |
tools/redis/overview.ts |
tools/redis/overview.ts |
redis-overview |
tools/redis/overview/info.ts |
tools/redis/overview/info.ts |
redis-overview-info |
目录层级被压平成一维名字。这带来一个隐蔽的冲突:tools/redis-overview.ts 和 tools/redis/overview.ts 都会得到 redis-overview。discovery 阶段不会报错,因为它们的 logicalPath 不同;冲突要等到编译时按名字去重才暴露。
保留的名字
同一个 normalize-tool.ts 里还有命名空间检查:
// 命名空间检查:只保留 eve 和 eve__ 前缀,其余名字作者随便用
const reservation = eveNamespaceReservation(toolName);
if (reservation !== undefined) {
throw new Error(`Tool "${source.logicalPath}" uses the reserved name "${toolName}". ...`);
}
eveNamespaceReservation 只保留两样东西:
// packages/eve/src/protocol/runtime-tools.ts:5
const EVE_NAMESPACE = "eve";
export function eveNamespaceReservation(name: string): string | undefined {
// 只拦 eve 本身和 eve__ 前缀;evefoo、my_eve 都不算
return name === EVE_NAMESPACE || name.startsWith(`${EVE_NAMESPACE}__`)
? `eve reserves the "${EVE_NAMESPACE}" namespace for its built-in tools`
: undefined;
}
所以 tools/eve.ts 和 tools/eve__debug.ts 会编译失败,其它名字都可以用——包括 bash、read_file 这些默认工具的名字。
用默认工具的名字不会报错,会覆盖它。 覆盖规则在 source-graph.ts 的层级优先级里:
// 四层优先级,数字大的赢:application 永远压过框架默认
const LAYER_PRECEDENCE: Readonly<Record<AgentSourceLayer, number>> = {
"framework-default": 0, // eve 自带的 bash、read_file
"extension-package": 1, // 扩展包提供的
"extension-override": 2, // 扩展包里的覆盖
application: 3, // 项目自己写的
};
application(你写的)优先级最高。写一个 tools/bash.ts 就把框架默认的 bash 换掉了,被换掉的那个记作 shadowed,在编译产物里能查到。这个机制在第 22 章展开。
21.6 第五个问题:撞名了怎么办
同名文件同时存在时,不是二选一,是整个 slot 消失:
// packages/eve/src/discover/named-source-directory.ts · collectLeafSources()
// markdown 和模块同时存在:报冲突,然后 continue——这个槽位一个都不留
if (candidates.markdownFileName !== undefined && candidates.moduleFileNames.length > 0) {
input.diagnostics.push(
createSlotCollisionDiagnostic(absoluteDirectory, slotLogicalPath, [
candidates.markdownFileName,
...candidates.moduleFileNames,
]),
);
continue; // ← 这个 slot 没了
}
// 多个模块文件(read_logs.ts 和 read_logs.js)同样整槽作废,不是二选一
if (candidates.moduleFileNames.length > 1) {
input.diagnostics.push(
createModuleSlotCollisionDiagnostic(absoluteDirectory, slotLogicalPath, candidates.moduleFileNames),
);
continue;
}
两种碰撞:
schedules/daily.md和schedules/daily.ts同时存在 →discover/slot-collisiontools/read_logs.ts和tools/read_logs.js同时存在 →discover/module-slot-collision
第二种最常见:编辑器自动生成 .js(比如 tsc 没配 noEmit),或者从别处拷文件时带了一份编译产物。
诊断信息里会列出所有冲突文件名,但不会告诉你要保留哪个。它只说「这几个文件撞了」。
continue 的后果是:这个工具/技能/定时任务完全不存在。不是用了其中一个,是一个都没有。
21.7 失败时怎么告诉你
诊断有两种级别,后果完全不同:
// packages/eve/src/compiler/compile-agent.ts:152
function finishAgentCompilation(result, createError): CompileAgentResult {
// 只要有一条 error 就整体失败,不产出「半个」编译结果
if (result.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
throw createError(result);
}
// warning 只打印,不阻塞编译
reportDiscoverWarnings(result.diagnostics);
return result;
}
| severity | 后果 | 什么时候出现 |
|---|---|---|
error |
编译抛错,eve dev 起不来 |
名字非法、槽位冲突、目录该是目录却是文件、缺 instructions |
warning |
打印一行,继续编译 | 未知根目录、system.* 废弃提醒 |
常见 error 码:
| 码 | 含义 | 修法 |
|---|---|---|
discover/tool-name-invalid |
工具文件名不合法 | 改成字母开头、只含 [a-zA-Z0-9_-] |
discover/slot-collision |
同名 md 和模块 | 删掉一个 |
discover/module-slot-collision |
同名多扩展名模块 | 删掉编译产物 |
discover/tools-directory-invalid |
tools 是文件不是目录 |
改名 |
discover/required-instructions-missing |
没有 instructions | 加 instructions.md |
discover/unsupported-directory |
未知根目录 | warning,不是 error |
诊断结果落在 .eve/discovery/diagnostics.json:
{
"diagnostics": [],
"kind": "eve-compiler-diagnostics",
"summary": { "errors": 0, "warnings": 0 },
"version": 2
}
eve info --json 的 diagnostics 字段就是这里的 summary。排查「为什么我的能力没出现」时,先看这个文件,比翻终端快。
21.8 产物长什么样
打开一个真实项目的 .eve/discovery/agent-discovery-manifest.json(instructions.content 为可读性截断):
{
"agentId": "opsbot",
"agentRoot": "/path/to/app/agent",
"appRoot": "/path/to/app",
"channels": [
{ "sourceKind": "module", "logicalPath": "channels/eve.ts", "sourceId": "channels/eve.ts" }
],
"connections": [],
"diagnosticsSummary": { "errors": 0, "warnings": 0 },
"extensions": [],
"resolvedExtensions": [],
"hooks": [],
"memories": [],
"instructions": [
{
"definition": {
"content": "# Identity\n\nYou are a general-purpose AI agent powered by eve...",
"role": "system"
},
"sourceKind": "markdown",
"logicalPath": "instructions.md",
"sourceId": "instructions.md"
}
],
"lib": [],
"kind": "eve-agent-discovery-manifest",
"sandbox": null,
"sandboxWorkspaces": [],
"schedules": [],
"skills": [],
"tools": [],
"version": 15,
"subagents": [],
"configModule": { "sourceKind": "module", "logicalPath": "agent.ts", "sourceId": "agent.ts" }
}
四个值得注意的点:
tools: []不代表没有工具。 这个项目确实没写工具,但即使写了,这里也只列作者的工具。默认的 bash、read_file 在编译产物里才出现。- markdown 的内容被内联了。
instructions的definition.content是全文,不是路径引用。所以改instructions.md会改变 manifest 的哈希,触发重新编译。 - 模块只存路径。
configModule只有logicalPath,没有内容——模块要等 runtime 阶段才求值(第 23 章)。 sourceId和logicalPath大多数时候相同。 扩展挂载时会不同,sourceId带命名空间前缀。
21.9 子代理的规则更严
子代理用另一张分类表:
// packages/eve/src/discover/filesystem.ts:237
export function classifyLocalSubagentEntry(
name: string,
entryType: DirectoryEntryType,
): LocalSubagentEntryKind {
// ... 文件槽位与 classifyAgentRootEntry 相同
// ... 目录槽位少了 channels 和 instrumentation
// 子代理不能有自己的定时任务:单独给一个「非法目录」类型,好出精确诊断
if (name === "schedules") {
return "invalid-schedules-directory";
}
// ...
}
对比:
| 槽位 | 根 agent | 子代理 |
|---|---|---|
tools/ hooks/ skills/ connections/ memory/ sandbox/ lib/ subagents/ |
有 | 有 |
channels/ |
有 | 没有 |
instrumentation/ |
有 | 没有 |
schedules/ |
有 | 报错 |
为什么少这三个?因为子代理不是一个独立部署单元——它只在父 agent 的会话里被调用。通道需要独立的 HTTP 入口,定时任务需要一个能独立启动的入口,两者都不成立。埋点则统一由根 agent 负责。
写 subagents/researcher/schedules/daily.ts 会得到 discover/schedules-directory-invalid 的 error,不是静默忽略。
21.10 回到开头那两个瞬间
现在能解释了。
read-logs.ts 为什么没被识别? 三种可能,按概率排:
- 名字不合法——
read-logs其实合法,但如果叫read.logs.ts就会得到read.logs,点号过不了正则。 - 同名文件撞了——比如
read_logs.ts和read_logs.js同时存在,整个 slot 消失。 - 文件写在了
tools/之外的目录,被当成未知目录忽略了。
排查顺序:看 .eve/discovery/diagnostics.json 的 summary.errors,非 0 就有明确原因;为 0 就看 manifest 的 tools 数组里有没有它。
agent/prompts/ 为什么完全没反应? 它不在分类表里,被当成未知目录,只生成一条 warning。而 warning 在 eve dev 的启动日志里会被刷过去。
21.11 五个注意事项
- 未知目录静默忽略。 建了
prompts/、context/、docs/都不会报错,只是没人读。唯一的线索是编译时的 warning,而它默认被启动日志刷掉。 agent.ts大小写敏感,instructions.md不敏感。 见 21.2 的表。tools/递归,工具名用连字符压平。tools/a/b.ts叫a-b,不是b。两个不同路径可能撞同一个名字,discovery 不报,编译才报。- 同名文件导致整个 slot 消失。 不是取其一,是一个都没有。
.js和.ts同时存在是最常见的触发方式。 .d.ts不算模块。 想在tools/里放类型定义可以,但别指望它变成工具。
21.12 练习
- 在
agent/tools/下建read-logs.ts和read_logs.ts,跑eve info --json,看tools数组里有几个。再改成read.logs.ts,看诊断码变成什么。 - 建
agent/tools/redis/overview.ts,导出任意defineTool。用eve info --json查toolInputSchemas.root的 key,确认工具名是redis-overview。 - 把
agent/instructions.md复制一份叫INSTRUCTIONS.MD,跑一次eve dev。看.eve/discovery/agent-discovery-manifest.json里instructions的logicalPath是哪个文件名——两个都匹配同一个槽位时不会报错,最终用哪个由localeCompare的排序结果决定,没有确定性保证。 - 建
agent/prompts/system.md,跑eve build,然后在.eve/discovery/diagnostics.json里找那条 warning。 - 在
agent/subagents/helper/下建schedules/daily.ts,确认编译报discover/schedules-directory-invalid。
下一章
扫描完成,AgentSourceManifest 交出去了。接下来要把它变成能跑的东西。
第 22 章讲编译。你会看到同一份 manifest 怎么和框架默认能力合并、为什么写 tools/bash.ts 能覆盖内置的 bash、以及 dev 下为什么改一行代码不影响正在跑的会话。
返回:教程目录 · 上一章:20 CLI 启动链路 · 下一章:22 compiler