跳到主要内容

文件系统即接口:发现与编译

30 秒导读: eve 是一个"文件系统优先"的后端 agent 框架。你不写一份大配置文件来声明 agent 有哪些工具、技能、指令、入口——你把文件放到约定好的目录里,文件的路径就决定了它是什么。agent/tools/refund.ts 自动成为一个名叫 refund 的工具;agent/instructions.md 自动成为系统提示。本章讲清楚这套"约定优于配置"的两段流水线:discover(读目录 → 产出 manifest)和 compile(规整 manifest → 写进 .eve/)。

本章只讲静态结构:目录怎么被解读成一个 agent 的"形状"。运行时怎么真正跑这个 agent(session/turn/step、harness 循环)是 02 章03 章 的事;instructions / skills / subagents 在运行时如何被注入上下文见 04 章;channels / connections 的前门与安全语义见 05 章


1. 这是什么(零基础也能懂)

一句话定义

eve 用目录结构当 agent 的声明式接口。 你不调用 addTool(...)、不维护一个 agent.config.json 里的工具数组;你把一个文件丢进 agent/tools/,它就成了一个工具。文件路径 = 它的身份。

解决什么问题 / 给谁用

假设你要做一个后端 agent:它有一段系统提示、几个自定义工具、几个技能、一个对外的 HTTP 入口。传统做法是写一份中心配置,手动把每个东西注册进去——配置和文件两头对账,容易漂移(文件改了名,配置忘了改)。

eve 的取舍是:让文件系统本身当那份配置。 约定好"哪个目录放什么",于是:

  • 写工具 = 在 agent/tools/ 下放一个 .ts
  • 写技能 = 在 agent/skills/ 下放一个 SKILL.md 包。
  • 改系统提示 = 编辑 agent/instructions.md

没有"注册"这一步,也就没有"忘了注册"这个 bug。这套思路对熟悉 Next.js 文件路由(pages/about.tsx 自动变成 /about 路由)的人会非常眼熟——eve 把同一个直觉推广到了 agent 的所有组成部件。

用起来什么样

一个最小 agent 的目录长这样(flat 布局):

my-agent/
├── package.json ← 项目标记文件(让 eve 认出这是个 app)
└── agent/
├── instructions.md ← 系统提示(必需)
├── agent.ts ← 可选的 agent 配置模块(模型、构建选项…)
├── tools/
│ ├── refund.ts → 工具 "refund"
│ └── billing/
│ └── invoice.ts → 工具 "billing-invoice"(路径被拍平成 slug)
├── skills/
│ └── triage/
│ └── SKILL.md → 技能 "triage"
└── channels/
└── webhook.ts → 一个 HTTP 入口

你不需要在任何地方"列出"这些工具/技能;eve 扫描目录就知道了。

一句话直觉

agent/ 目录当成一张填空表:每个约定目录是一个"槽位"(slot),你往里放文件,就是在填这张表。 discover 是"读表的人",compile 是"把表誊抄成机器能读的卡片的人"。


2. 顶层全景(它大概怎么转)

从"一个目录"到"一份机器可读的 agent 描述",要走两段流水线。先看整体数据流(从左到右,每个方框是一个阶段,产物用 标出):

起始路径 (cwd)


┌─────────────────────┐
│ resolveDiscovery │ 向上走父目录,找到 appRoot + agentRoot
│ Project │ → { agentRoot, appRoot, layout: flat|nested }
└─────────┬───────────┘


┌─────────────────────┐
│ discoverAgent │ 扫 agentRoot 的每个 slot,逐目录套语法规则
│ (走 grammar slots) │ → AgentSourceManifest(还没加载任何模块)
└─────────┬───────────┘


┌─────────────────────┐
│ compileAgentManifest│ 规整每个 slot:加载模块、派生工具名、组合指令
│ (normalize-*) │ → CompiledAgentManifest
└─────────┬───────────┘


┌─────────────────────┐
│ writeCompilerArtifacts│ 序列化 + 写盘
└─────────┬───────────┘


.eve/discovery/*.json
.eve/compile/*.json + module-map.mjs

各部件一句话职责:

部件干什么文件
resolveDiscoveryProject从任意起点向上找到 app 根与 agent 根,判定 flat/nestedsrc/discover/project.ts:52
discoverAgent按 slot 语法扫 agent 根,产出未加载模块的 source manifestsrc/discover/discover-agent.ts:59
grammar / slots定义每个 slot 的命名/字符集规则、碰撞检测src/discover/grammar.tssrc/discover/slots.ts
classifyAgentRootEntry把目录里的每个条目归类成某个 slot(或 "unknown")src/discover/filesystem.ts:116
createAgentSourceManifest把所有 slot 结果装进一个版本化的 manifestsrc/discover/manifest.ts:251
compileAgentManifest规整:加载模块、派生名字、组合静态指令src/compiler/normalize-manifest.ts:31
writeCompilerArtifacts把编译后的 manifest 等写进 .eve/src/compiler/artifacts.ts:201

两段分工的关键: discover 只读目录结构,不 import 任何 authored 模块(注释明说 "without importing authored modules",discover-agent.ts:55-58)。它产出的 AgentSourceManifest 里只有"在 tools/refund.ts 有个工具"这种引用,而非工具的真实定义。真正执行 refund.ts、读出它的 description/schema,是 compile 阶段的事。这条分界让 discover 又快又纯——可以在内存树上跑、可以被静态分析,不触发任何用户代码副作用。


3. 核心原理之一:从起点找到 agent 根(resolve)

它要解决的小问题

你可能在项目里任何子目录下运行 eve(比如 my-agent/agent/tools/ 里)。eve 得先回答:"这个 agent 的根在哪?整个 app 的根又在哪?"

思路

像 git 找 .git、Node 找 node_modules 一样:从当前目录开始,一层层往上走父目录,直到认出一个 agent 根。 eve 支持两种布局,resolve 在每一层依次试三种判定:

当前目录每往上走一层,依次问三个问题(命中即停):

①「我自己叫 agent/,且父目录有 package.json?」
→ nested 布局:agentRoot=我, appRoot=父
②「我有 package.json,且我下面有个 agent/ 子目录?」
→ nested 布局:agentRoot=我/agent, appRoot=我
③「我直接就含 agent 的 slot 文件(instructions / tools/ …)?」
→ flat 布局:agentRoot=appRoot=我

都不命中 → 向上一层,重试;到文件系统根还没命中 → 报 PROJECT_NOT_FOUND

这三问对应 resolveDiscoveryProject 主循环里的三次尝试(project.ts:60-94):tryResolveNestedProjectFromAgentDirectory(project.ts:116)、tryResolveNestedProjectFromAppRoot(project.ts:137)、isFlatAgentRoot(project.ts:158)。

flat vs nested 两种布局

布局形状agentRootappRoot
flatmy-agent/ 直接放 instructions.mdtools/= appRoot同一目录
nestedmy-agent/package.json,agent 内容收进 my-agent/agent/my-agent/agentmy-agent

nested 让 agent 的源文件和项目的其它东西(package.json、构建脚本、.eve/)分层;flat 适合"整个项目就是一个 agent"的极简场景。

关键细节:什么算"项目标记"

nested 的判定依赖"父目录是不是一个 app 根"。判定标准很窄——目录里有 package.json vercel.json 这两个文件之一(PROJECT_MARKER_FILE_NAMES,filesystem.ts:20;判定在 isProjectMarkerEntry,filesystem.ts:109)。vercel.json 出现在这里点出了 eve 的部署目标:Vercel 上的后端 agent。

而 flat 的判定(isFlatAgentRoot,project.ts:158)是:目录里至少有一个条目被归类成已知 slot,且这个 slot 不是 unknown 也不是单纯的 lib/(只有 lib/ 不足以认定为 agent 根)。


4. 核心原理之二:slot 语法——一个文件如何获得身份

这是本章的心脏。slot 是 eve 对"agent 根下某个约定位置"的称呼。discover 扫到 agent 根的每个条目,先用 classifyAgentRootEntry(filesystem.ts:116)给它归类,再交给对应 slot 的发现逻辑。

4.1 slot 总览

agent 根下支持的 slot,以及它们的形态:

slot文件系统形态产出必需?
instructionsinstructions.md / instructions.{ts,…} / instructions/ 目录系统提示
agent 配置agent.{ts,cts,mts,js,cjs,mjs}agent 级配置(模型、构建…)
toolstools/ 目录(递归)工具
hookshooks/ 目录(递归)生命周期钩子
channelschannels/ 目录(递归)HTTP 入口
connectionsconnections/ 目录外部连接(file 形或 folder 形)
skillsskills/ 目录(包形或 flat)按需技能
schedulesschedules/ 目录(递归)定时任务
sandboxsandbox/ 目录 或 sandbox.{ts,…}沙箱定义 + workspace
subagentssubagents/ 目录(递归)子 agent
liblib/ 目录包内辅助模块

discoverAgent(discover-agent.ts:59)把这些 slot 一个个发现完,再装进 manifest——可以把它读成一份"目录契约清单"。归类成 unknown 的目录会被忽略并发一条 warning("Ignoring unsupported directory…",discover-agent.ts:67-76),不会让构建失败。

4.2 两种文件:markdown vs module

一个 slot 的内容可以是两种来源之一:

  • module(.ts/.cts/.mts/.js/.cjs/.mjs):一段导出 defineXxx({...}) 的代码。支持的扩展名列在 SUPPORTED_AUTHORED_MODULE_FILE_EXTENSIONS(filesystem.ts:7)。
  • markdown(.md):带 frontmatter 的纯文本,用于 instructions / skills / schedules。

discover 阶段只引用文件,不读 module 的内容(只有 markdown 会被立刻解析 frontmatter,因为那是纯数据)。

4.3 instructions:必需槽 + 废弃回退

instructions 是唯一必需的 slot,它的发现逻辑(discoverInstructionsSource,grammar.ts:171)最能体现 eve 对"约定 + 兼容"的处理,支持三种形态:

按以下顺序找 instructions(命中即停):

1. instructions/ 是目录?
→ 目录形:收集里面所有 .md / .ts;
若同时还有顶层 instructions.md/.ts,它排在最前
2. instructions.md 或 instructions.{ts,…} 存在?
→ flat 文件形(单个来源)
3. 都没有,但有 system.md / system.{ts,…}?
→ 【废弃回退】仍然能用,但发一条 deprecation warning,
提示改名为 instructions.*
4. 全都没有?
→ 报错 DISCOVER_REQUIRED_INSTRUCTIONS_MISSING

真实代码里第 3 步的废弃逻辑很清晰:找到 system.* 时,它照常返回 instructions,但额外塞一条 DISCOVER_DEPRECATED_SYSTEM_SLOT 警告(grammar.ts:232-246),措辞是"运行时暂时还加载这个旧槽,但未来版本会移除"。第 4 步的缺失错误(grammar.ts:262-272)会逐字列出它接受的所有文件名,对作者很友好。

system 槽的废弃常量本身也带文档注释,说明它"resolves successfully; the warning prompts the author to rename the file"(grammar.ts:30-36)。

4.4 命名 charset:为什么 tool 名和 channel 名规则不同

每个 slot 对文件名的合法字符集有不同的正则,这不是随意的——规则反映了这个名字最终会流到哪里:

slot正则常量规则为什么
toolsTOOL_SLUG_PATTERN (grammar.ts:107)字母开头,字母/数字/_/-,≤64文件名逐字就是给模型看的 tool name;64 是各家 provider 最严的上限
hooksHOOK_SLUG_PATTERN (grammar.ts:128)同 tool 字符集内部 slug,无 URL 语义,不许 [param]
connectionsCONNECTION_SLUG_PATTERN (grammar.ts:114)小写 kebab,字母开头不直接暴露给模型,用更克制的小写规则
channelsCHANNEL_SLUG_PATTERN (grammar.ts:119)小写 kebab,可带前导 .,或 [sessionId] 参数形段会变成 URL 路径段,所以允许 .well-known 和路径参数

tool 名的设计取舍很值得注意(normalize-tool.ts 的注释,normalize-tool.ts:23-34):tool 是唯一一个会把嵌套目录拍平成单段 slug 的 slot——tools/billing/refund.ts 编出来的 tool 名是 billing-refund,因为大多数 provider 拒绝 tool 名里出现 /。而且不允许在代码里写 name 字段覆盖——文件名就是名字,想要 snake_case 就把文件命名成 snake_case。这是"约定优于配置"最锋利的一刀:连"给工具起名"这个自由都收走了,以换取"路径 = 身份"的绝对一致。

charset 校验在 discover 阶段就发生——discoverNamedSourceDirectory 的调用方把 createToolNameDiagnostic 等当作 validateSegment 传进去(discover-agent.ts:132-141),在 compiler 加载模块之前就把非法文件名挡掉(grammar.ts:407-422 的注释明说这一点)。

4.5 碰撞 diagnostics:同一个槽不能有两个来源

"约定优于配置"的代价是:必须无歧义。如果一个 slot 同时被两个文件填了,eve 不会猜,而是报错。碰撞分两类:

  • slot collision(markdown + module 都填了同一槽):DISCOVER_SLOT_COLLISION,如 instructions.mdinstructions.ts 并存(grammar.ts:291-300grammar.ts:531-541)。
  • module slot collision(同名不同扩展的多个 module):DISCOVER_MODULE_SLOT_COLLISION,如 refund.tsrefund.js 并存(grammar.ts:302-312grammar.ts:546-556)。

每条碰撞 diagnostic 都把冲突的文件名列出来,作者一眼能定位。

4.6 flat vs nested(在目录内部)与 named-source-directory 走法

除了 4.3 的 instructions,其余目录型 slot(tools/channels/hooks/schedules/lib/)共用一个通用遍历器 discoverNamedSourceDirectory(named-source-directory.ts:108)。它的行为由两个开关控制:

  • recursive:true 时深入子目录(channels/hooks/schedules/tools 都递归);子目录优先于叶子文件遍历,产出深度优先 + 同层字典序的稳定顺序(named-source-directory.ts:99-107 注释)。
  • allowMarkdown:true 时除 module 外还收 .md(只有 schedules/instructions/ 用到)。

每下钻一层,目录段名也会过一遍 validateSegment(named-source-directory.ts:238-244),所以非法的子目录名同样会被挡下,而不是只校验叶子文件。

4.7 一个特例:connections 的 file 形 vs folder 形

connections 是少数允许两种布局的 slot(connections.ts:51):

  • file 形:connections/linear.ts → 连接名 linear
  • folder 形:connections/linear/connection.ts → 连接名也是 linear,文件夹名就是连接名。

两种形态混用同一个名字会触发 DISCOVER_CONNECTION_FILE_FOLDER_COLLISION(connections.ts:136-154);folder 形里缺 connection.ts 会触发 DISCOVER_CONNECTION_FOLDER_EMPTY(connections.ts:180-191)。manifest 里的 ConnectionSourceRef显式带上 connectionName(manifest.ts:36-43),省得 compiler 再从路径反推。


5. 核心原理之三:manifest——发现的产物

discover 的最终产物是一个 AgentSourceManifest(manifest.ts:138),由 createAgentSourceManifest(manifest.ts:251)装配。它是个版本化结构(AGENT_SOURCE_MANIFEST_VERSION = 12,manifest.ts:23),每个 slot 一个字段:instructionstoolschannelsconnectionsskillsschedulessandboxhookslibsubagents

它的关键性质:

  • 只含引用,不含定义。 每个条目是个 ModuleSourceRef(有 logicalPath / sourceId / 可选 exportName),指向"哪个文件",而不是文件导出了什么。
  • 诊断被汇总进去。 diagnosticsSummarysummarizeDiscoverDiagnostics 统计 error/warning 数,挂在 manifest 上(manifest.ts:262)。
  • agentId 从路径派生。 deriveAgentIdFromRoots(manifest.ts:290)优先用 app 的 package.jsonname(去掉 npm scope),否则退回 basename(appRoot)。注释解释了为什么:CI 里工作目录常是 /vercel/path0 这种合成路径,直接取 basename 会得到没意义的 path0,所以优先信 package name。

logicalPath 全程用 normalizeLogicalPath(filesystem.ts:320)统一成正斜杠、去掉前导 ./——保证 manifest 在不同 OS 上一致。

subagents:递归的 manifest

subagents/ 里每个子 agent 自己又是一份小型 manifest(LocalSubagentSourceRef.manifest,manifest.ts:62-69)。discoverSubagents(discover-subagent.ts:74)对每个子 agent 包重跑一遍同样的 slot 发现(tools/hooks/instructions/sandbox…),并能再向下递归(discover-subagent.ts:276-281)。两处差异:子 agent 的 agent.{ts,…} 配置模块是必需的(discover-subagent.ts:211-221),且子 agent 不许定义 schedules/(会报 DISCOVER_LOCAL_SUBAGENT_SCHEDULES_INVALID,discover-subagent.ts:317-337)。这点呼应了 04 章 讲的 subagent 边界。


6. 核心原理之四:compile——把 manifest 誊抄成机器卡片

compileAgent(compile-agent.ts:62)把三步串起来:resolve → discover → writeCompilerArtifacts。中间真正的"规整"在 compileAgentManifest(normalize-manifest.ts:31)。

compile 干的几件实事

discover 留下的是"引用";compile 负责把引用变成可执行的定义,核心动作:

  1. 加载 module 并规整。 对每个 tool/channel/skill/instruction 源,真正 import 模块、读出 defineXxx 的产物,跑对应的 normalize-*(normalize-manifest.ts:83-198)。这是第一次真正执行 authored 代码。
  2. 派生最终名字。 tool 名在这里从 logicalPath 算出:stripLogicalPathExtension 去扩展名、去 tools/ 前缀、把 / 换成 -(normalize-tool.ts:49-51)。
  3. 分流静态/动态。 一个 tool 源可能是真工具、是"禁用某个框架默认工具"的标记、是"开启 workflow"的开关、或一个运行时动态 tool 解析器——compile 把它们分到 tools / disabledFrameworkTools / workflowEnabled / dynamicTools(normalize-manifest.ts:83-103)。instructions 同理分静态/动态(normalize-manifest.ts:132-146)。
  4. 组合多份静态 instructions。instructions/ 目录有多个文件,它们的 markdown 会被用 \n\n 拼成一份(normalize-manifest.ts:148-159)。
  5. 递归编译 subagent 图。 compileSubagentGraph 把子 agent manifest 也编出来,形成 nodes/edges(normalize-manifest.ts:38-45)。

产物是 CompiledAgentManifest——运行时直接吃这份。注意 channel 的一个细节:一个 channel module 的每条 route 会展开成一个独立的 compiled channel 条目(normalize-channel.ts:51-62),URL 路径来自 route 的 path,channel 名来自文件路径。

compile 把东西写到哪

writeCompilerArtifacts(artifacts.ts:201)把产物落到 app 根下的 .eve/,路径由 resolveCompilerArtifactPaths(artifacts.ts:117)固定:

产物路径内容
discovery manifest.eve/discovery/agent-discovery-manifest.jsondiscover 的原始 source manifest
diagnostics.eve/discovery/diagnostics.json所有 error/warning + 汇总
compiled manifest.eve/compile/compiled-agent-manifest.json规整后、运行时直接读的 manifest
module map.eve/compile/module-map.mjslogicalPath → 真实 import 的映射
channel 类型.eve/compile/channel-instrumentation-types.d.tschannel 的 TS 类型声明
compile metadata.eve/compile/compile-metadata.json各产物的 sha256 摘要 + status

compile-metadata.json 给每个产物记 sha256,并算一个 sourceGraphHash(artifacts.ts:185);status 是 failed 还是 ready 取决于有没有 error(artifacts.ts:193)。

失败语义:产物先写、再抛错

一个值得借鉴的设计:即使 discover 有错,compileAgent 也会先把产物(含 diagnostics)写盘,再抛 CompileAgentError(compile-agent.ts:66-81)。这样错误信息既进了 stderr,也落了盘——工具链可以去读 diagnostics.json 而不必解析异常文本。warning(如废弃的 system 槽)则只打印、不阻断(compile-agent.ts:88-98)。


7. 巧妙之处(可借鉴的技术)

  • ProjectSource 抽象让发现可在内存树上跑。 所有文件系统读取都走一个 ProjectSource 接口,默认是磁盘实现(createDiskProjectSource),但测试能传一个内存实现跑整套发现(discover-agent.ts:38-45 注释)。发现逻辑因此纯函数化、易测、无副作用。

  • discover 不 import 用户模块。 把"读结构"和"执行代码"切成两段(§2),让 discover 又快又安全;只有 compile 才触发 authored 代码。

  • charset 规则跟着"名字流向哪里"走。 tool 名直进模型 API → 用 provider 最严的字符集和长度;channel 段进 URL → 允许 .well-known[param](§4.4)。规则不是统一的,而是贴着下游约束定的。

  • tool 名零覆盖、强制路径派生。 不给 name 字段留后门(normalize-tool.ts:34),用"嵌套拍平成 -"换"路径=身份"的绝对一致——把约定贯彻到底。

  • agentId 优先信 package name 而非 basename。 一行小判断躲开了 CI 合成路径(/vercel/path0)产出垃圾 id 的坑(manifest.ts:290-306)。

  • 产物先写后抛。 失败也留下机器可读的 diagnostics 落盘,工具链不必解析异常字符串(§6)。


8. 边界与局限(诚实)

  • 必需且唯一的 instructions。 没有 instructions(且没有废弃的 system.*)直接构建失败;同一槽两个来源也直接失败——eve 绝不猜,无歧义是硬约束。

  • system.* 是过渡期产物。 它现在还能用但已废弃(grammar.ts:30-36),会发 warning,未来移除。读老项目时会撞到。

  • unknown 目录被静默忽略(只 warn)。 放错位置的目录不会报错、只发一条 warning(discover-agent.ts:67-76),所以"我加了个工具怎么没生效"常常是放进了非约定目录——得去看 warning。

  • subagent 不能有 schedules、必须有 agent.* 配置。 子 agent 的 slot 集合是主 agent 的子集,有额外约束(§5);这是刻意的边界,不是 bug。

  • 本章不覆盖运行时。 manifest/产物怎么被运行时加载执行——session 持久化、harness 循环、上下文注入——分别见 02 / 03 / 04 / 05


9. 代码地图(导航索引)

主题文件路径符号名
解析 app/agent 根 + flat/nestedsrc/discover/project.tsresolveDiscoveryProject
nested 三问之一/二/三src/discover/project.tstryResolveNestedProjectFromAgentDirectory / tryResolveNestedProjectFromAppRoot / isFlatAgentRoot
项目标记文件src/discover/filesystem.tsPROJECT_MARKER_FILE_NAMES / isProjectMarkerEntry
根条目归类成 slotsrc/discover/filesystem.tsclassifyAgentRootEntry
主发现入口(走所有 slot)src/discover/discover-agent.tsdiscoverAgent
instructions 槽(三形态 + 废弃回退)src/discover/grammar.tsdiscoverInstructionsSource
tool/channel/connection/hook charsetsrc/discover/grammar.tsTOOL_SLUG_PATTERN / CHANNEL_SLUG_PATTERN / CONNECTION_SLUG_PATTERN / HOOK_SLUG_PATTERN
charset 校验诊断src/discover/grammar.tscreateToolNameDiagnostic / createChannelNameDiagnostic
碰撞诊断src/discover/grammar.tscreateSlotCollisionDiagnostic / createModuleSlotCollisionDiagnostic
通用目录遍历(flat/nested/markdown)src/discover/named-source-directory.tsdiscoverNamedSourceDirectory
槽候选收集src/discover/slots.tscollectFlatSlotCandidates / collectNamedSlotCandidates
connections file/folder 形src/discover/connections.tsdiscoverConnectionSources
skills 包形/flat 形src/discover/skills.tsdiscoverSkills
subagent 递归发现src/discover/discover-subagent.tsdiscoverSubagents
source manifest 结构 + 版本src/discover/manifest.tsAgentSourceManifest / AGENT_SOURCE_MANIFEST_VERSION
agentId 派生src/discover/manifest.tsderiveAgentIdFromRoots
路径规整src/discover/filesystem.tsnormalizeLogicalPath / getSupportedModuleBaseName
compile 入口(resolve→discover→write)src/compiler/compile-agent.tscompileAgent
manifest 规整(加载模块、分流)src/compiler/normalize-manifest.tscompileAgentManifest
tool 名派生 + 路径拍平src/compiler/normalize-tool.tscompileToolEntry
instructions 组合/动态分流src/compiler/normalize-instructions.tscompileInstructionsEntry
channel route 展开src/compiler/normalize-channel.tscompileChannelDefinition
写 .eve/ 产物 + 路径布局src/compiler/artifacts.tswriteCompilerArtifacts / resolveCompilerArtifactPaths