Skip to content

AGENTS.md Instruction Loader

本课交付结果

你将交付 loadInstructions({ root, targetPaths, maxBytes }):只读取每个目标祖先链上的 AGENTS.md,按根级到更深目录排序;多目标共享指令只加载一次,并用 realpath 与总字节预算限制输入。

岗位问题

大型 monorepo 的不同目录有不同约束。全仓库扫描会把无关甚至冲突指令都塞给模型;只读根文件又会漏掉局部测试和风格规则。symlink 还可能把“指令文件”指向工作区外秘密。

前置检查

前置知识快照

回顾第 8 课词法与 realpath 双层边界:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 08

目标路径可以尚不存在,但必须是工作区内相对路径;存在的 AGENTS.md 必须解析后仍在真实 root 内。

Instruction Loader不是全仓库搜索。它根据本次要修改的targetPaths计算作用域:根规则对全仓库生效,越靠近目标的局部规则越具体,完全无关目录的指令不进入上下文。这样既减少token,也避免API规则污染Web任务。

路径边界复用第8课:target拒绝绝对与..,root先realpath;每个实际AGENTS.md也realpath,防止工作区内symlink指向用户主目录秘密。提示词说“不要越界”不能替代读取层强制。

原理拆解

apps/web/src/a.ts 构造祖先链 root → apps → apps/web → apps/web/src,逐层查找 AGENTS.md。用 Set 保持首次发现顺序并为多目标去重。读取每个文件前 realpath,累加 UTF-8 字节数而非字符数。

mermaid
flowchart TD
  A[root + targetPaths] --> B[realpath root]
  B --> C[校验每个相对target]
  C --> D[构造根到目标目录祖先链]
  D --> E[逐层候选AGENTS.md]
  E --> F{已seen?}
  F -- 是 --> G[跳过共享祖先]
  F -- 否 --> H{文件存在?}
  H -- 否 --> I[继续]
  H -- 是 --> J[realpath仍在root?]
  J -- 否 --> K[拒绝逃逸]
  J -- 是 --> L[读取并累计UTF-8字节]
  L --> M{总预算内?}
  M -- 否 --> N[失败]
  M -- 是 --> O[按发现顺序返回]

目标路径可以尚不存在,因为Agent可能计划新建文件;祖先链基于词法目标目录构造。存在的指令文件才访问磁盘并验证真实路径。不能为了验证不存在target调用realpath,否则所有新文件任务都失败。

顺序决定覆盖语义。根指令先进入上下文,局部随后,模型与规则合并器可让更具体约束补充或覆盖全局。不要按文件名全局排序,apps/web/AGENTS.md若跑到根前,优先级含义倒置。

多目标按输入顺序遍历,各自祖先从根到深层;Set对完整候选路径去重,所以共享根只出现一次。web/api示例得到root、web、api。若目标顺序反转,则root、api、web;跨分支没有天然深浅优先,调用方顺序应稳定或按规范target排序。

预算按Buffer.byteLength(content),不是字符串length。中文与emoji占多个UTF-8字节,磁盘和模型输入成本不能用UTF-16近似。每个文件读取后累计,超过总上限整次失败,不返回部分规则让Agent误以为指令完整。

代码实验

失败实现

递归扫描全仓并按文件名排序会加载无关规则,也看不到链接逃逸:

ts
const files = await glob("**/AGENTS.md", { cwd: root });
return Promise.all(files.sort().map(async (path) => ({
  path,
  content: await readFile(join(root, path), "utf8"),
})));

正确实现从每个target祖先链构造候选,并对真实文件做边界检查:

ts
const realRoot = await realpath(options.root);
for (const targetPath of options.targetPaths) {
  if (isAbsolute(targetPath) || targetPath.split(/[\\/]/).includes("..")) {
    throw new Error("目标路径超出工作区");
  }
  const target = resolve(realRoot, targetPath);
  const segments = relative(realRoot, dirname(target)).split(sep).filter(Boolean);
  let directory = realRoot;
  for (const segment of ["", ...segments]) {
    if (segment) directory = join(directory, segment);
    const instruction = join(directory, "AGENTS.md");
    if (seen.has(instruction)) continue;
    seen.add(instruction);
    // 存在时 realpath、边界比较、读取和预算累计
  }
}

候选在检查存在前加入seen,多个target共享一个缺失路径也只stat/realpath一次。若文件在两次目标遍历间被创建,单次load仍保持首次快照语义;下一次调用会读取。长运行缓存需要文件watch或内容hash失效。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/24-instruction-loader/starter test
pnpm --dir bootcamps/coding-agent/labs/24-instruction-loader/solution test

Starter 应声明 实现嵌套指令优先级;Solution 应通过 5 项测试。

symlink逃逸测试使用真实外部临时目录:

ts
it("拒绝链接到工作区外的指令", async () => {
  await writeFile(join(outside, "AGENTS.md"), "secret");
  await rm(join(root, "apps/web/AGENTS.md"));
  await symlink(
    join(outside, "AGENTS.md"),
    join(root, "apps/web/AGENTS.md"),
  );
  await expect(loadInstructions({
    root,
    targetPaths: ["apps/web/src/a.ts"],
    maxBytes: 100,
  })).rejects.toThrow("工作区");
});

关键实现讲解

具体例子:根 AGENTS.md 写“中文输出”,apps/web/AGENTS.md 写“运行前端测试”,apps/api/AGENTS.md 写“运行 API 测试”。只改 web 时得到 [root, web];同时改 web/api 时得到 [root, web, api],root 不重复。

外部路径判断使用relative(realRoot, actual),结果等于..、以../开头或为absolute即越界。字符串startsWith会被相似前缀绕过;root与actual都必须是realpath,避免macOS系统别名造成误判。

ENOENT只代表该层没有AGENTS.md,继续祖先链;EACCES、ELOOP和越界错误必须抛出。把所有catch当不存在会在权限或链接攻击时静默少加载规则,让Agent在未知约束下继续。

返回path应是规范工作区相对候选路径,不暴露realpath外部细节;content来自actual。若链接仍指向工作区内部,产品可允许并保留逻辑path,也可拒绝所有链接以简化来源。课程允许内部链接、拒绝外部目标。

Instruction内容是不可信文本,即使来自仓库内部。Loader只负责范围、安全和预算,不把指令升级成系统权限;后续Prompt组装标注来源,组织安全策略始终高于仓库文本。恶意AGENTS.md不能授权网络、shell或外部文件。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 24
pnpm --filter @coding-agent/repo-context test

测试还把 web 指令 symlink 到外部 secret 文件,loader 必须以“超出工作区”拒绝,而不是读取内容。

真实Solution五项应全部通过:

text
solution: 5 passed, 0 failed
覆盖: 根到局部顺序 / 排除无关规则 / 多目标祖先去重 / 总字节上限 / symlink逃逸

再调换web/api target顺序、使用中文内容与恰好预算、空target列表、绝对/上跳路径、内部symlink和权限错误。每次失败确认没有返回部分entries或泄露外部内容。

常见失败与排查

故障案例 1

Web任务加载了API目录规则。

症状:模型同时收到互相冲突的前端与后端测试命令,执行错误门禁。

根因:全仓递归扫描所有AGENTS.md,没有按target祖先链筛选。

定位:工作区同时放web/api规则,只传web target,检查entries是否含api。

修复:从每个目标目录向根构造精确祖先链,不遍历兄弟与后代。

故障案例 2

局部规则被根规则意外覆盖。

症状:web明确要求专用测试,但Prompt最终只采用根通用命令。

根因:加载顺序深到浅或全局字典排序,与“更具体后出现”语义相反。

定位:检查entries顺序和Prompt合并顺序,根必须在web之前。

修复:每条祖先链从realRoot向下,保留首次发现顺序;同层冲突显式诊断。

故障案例 3

仓库内指令链接泄露外部secret。

症状:模型上下文出现用户主目录或临时外部文件内容。

根因:只验证候选字符串在root下,readFile跟随symlink到外部。

定位:创建真实外部文件与工作区内链接,比较instruction realpath相对root。

修复:存在文件先realpath再做路径段边界;越界拒绝且不回显真实目标。

课后作业

实现指令来源行号、相同层级冲突诊断和缓存失效;写测试覆盖目标目录本身、Windows 分隔符及 symlink 目录链。

进阶要求是解析指令frontmatter中的scope、priority和schemaVersion,但任何声明都不能扩大文件系统作用域。生成规范provenance hash,把实际加载文件、内容hash、顺序与预算写进Trace,Prompt审批绑定该hash。

验收 Rubric

维度通过标准常见扣分
相关性只加载目标祖先链全仓扫描
顺序根到嵌套且多目标稳定深度或文件系统顺序
安全realpath 后仍在 rootsymlink 泄漏外部内容
预算累计 UTF-8 字节无上限或按字符

总项目增量

总项目包:@coding-agent/repo-context

总项目路径:packages/repo-context/src/instruction-loader.ts

总项目验证命令:pnpm --filter @coding-agent/repo-context test

指令决定“怎么做”;下一课选择“为当前失败具体看哪些代码”。

延伸阅读

方案对比与工程取舍

全仓扫描最容易实现,相关性和安全最差;只读根规则稳定但忽略局部约束;祖先链加载符合多数分层配置系统,要求目标路径已知;显式manifest能声明复杂scope,配置与治理更重。Coding Agent已有计划修改路径,因此祖先链是自然选择。

从目标向上收集再reverse也能得到根到局部,但多目标去重和跨分支顺序更难直观;课程从根向下构造每条链,Set保持首次发现。算法复杂度与目标深度成正比,不与仓库总文件数相关,适合大型monorepo。

目录本身作为target时,dirname(target)会取父目录,可能漏掉目标目录内AGENTS.md。接口应明确targetPaths表示文件路径;若支持目录,增加kind或通过stat/尾斜杠区分,不能猜。课程夹具都是目标文件。

Windows输入可能用反斜杠,POSIX Node的path模块不会把它当分隔符;词法检查已同时split两种,但resolve语义仍随平台。跨平台调用先将受支持输入规范为本机路径,拒绝盘符与UNC,测试在真实平台运行。

大小写不敏感文件系统中agents.md与AGENTS.md是否等价要由约定决定。固定精确文件名最可重复;不要扫描大小写变体后因目录顺序选择。文档要求仓库使用标准名称,CI检查拼写。

指令文件本身可能很大或包含二进制/非法UTF-8。读取前stat单文件上限,严格文本解码,累加总字节;任何文件不合法整次失败。返回部分层级会让安全约束缺失,除非产品明确标记incomplete并禁止写操作。

预算超限可以给出各文件字节与超限路径,帮助用户压缩,但不回显内容。不能简单从末尾丢局部规则或从开头丢根规则,二者都可能是关键约束。让用户提高受信预算或重构指令后重试。

Prompt预算还包含路径标题、分隔符与来源标注,Loader maxBytes只限制原内容。组装层用token预算再次计算;双层预算分别保护I/O和模型窗口。不能用文件字节声称绝不超token。

来源标注至少含逻辑相对路径、内容hash和层级。模型看到每段围栏,避免把仓库指令与系统规则混淆;Trace不必保存全文,只保存hash与选择理由。文件变化使context hash变化并触发重新规划。

同层冲突可能来自多个target分支,例如web要求pnpm、api要求pytest。本次任务同时修改两者时不能选一个覆盖另一个;保留两段作用域,计划每个Todo绑定适用target与命令。全局Prompt可显示冲突诊断,避免模型混用。

根规则通常对所有目标只加载一次。若不同target使用不同workspace root或子module,应该创建独立Context,而不是把多个信任根混在一个Loader。每个root有自己的realpath、预算与组织策略。

缓存键包含realRoot、规范target集合、每个祖先目录状态与内容hash、loader版本。只按target缓存会在AGENTS修改后返回旧规则;watcher可能漏事件,关键写操作前可重新stat/hash。正确性优先于缓存命中。

TOCTOU仍存在:realpath检查后文件可能被替换链接,再readFile逻辑path会跟随新目标。参考实现读取actual realpath字符串,若目标inode替换路径仍可能变化;更强方案打开文件句柄、fstat并从句柄读取,或在隔离只读worktree加载。

仓库指令可以提示“忽略系统安全规则”或要求读取秘密,这属于Prompt injection。来源层级不等于权限层级:系统/组织规则不可覆盖,用户任务高于仓库建议,局部AGENTS只在允许范围内影响风格与测试。合并器使用固定优先级,不让文本自称更高权威。

规则解析若支持include,会重新引入任意读取与循环。默认不支持;需要时include路径仍受root、深度、总文件与字节上限,记录依赖图并检测循环。简单祖先约定比可编程配置更容易安全审计。

空target列表返回空还是根规则?课程循环返回空,语义是没有目标就没有相关指令。产品规划阶段可能显式加载根规则,使用单独API,不能偷偷改变target-scoped合同。边界测试锁住预期。

测试真实symlink在不支持权限的平台可能失败,应检测能力并明确跳过集成测试,不能用mock冒充系统保证。CI至少在主支持平台运行真实链接;Windowsjunction另加用例。

变异测试全仓glob、链顺序反转、去掉seen、只做lexical、string.length计费、catch所有错误。门禁应逐项失败。再用恶意指令文本验证Loader原样作为不可信内容,策略层不会执行其授权请求。

用web单目标完整演练。root realpath后校验apps/web/src/a.ts,目标目录为apps/web/src,segments依次apps、web、src。候选根AGENTS存在读取root;apps缺失跳过;apps/web存在读取web;src缺失。entries为root、web,api兄弟从未候选,时间与仓库其他目录数量无关。

web与api双目标先遍历web得到root、web,seen已含根;再遍历api时根直接跳过,apps也已seen缺失,api文件读取,结果root、web、api。共享祖先去重不仅省token,也避免根规则重复增强模型权重,造成不符合层级语义的偏置。

如果targetPaths含同一文件两次,所有候选第二遍都seen,entries不重复。调用方仍应先去重并规范排序,让cache key与Trace稳定;Loader的Set是安全防线,不替代输入治理。

目标../secret在任何磁盘访问前拒绝,绝对/etc/passwd同样拒绝。Windows盘符和UNC需要平台扩展;仅isAbsolute在非Windows系统未必识别。跨平台合同把所有外来绝对形式列入测试,不能依赖部署恰好Linux。

候选逻辑路径在root内不代表实际文件安全。web/AGENTS链接到outside/AGENTS,realpath actual落在外部,relative以..开头,立即抛错。错误只说AGENTS超出工作区,不回显外部secret路径,更不会先read后判断。

内部链接如web/AGENTS指向root/shared/instructions可通过真实边界,但返回path仍是apps/web/AGENTS,表达规则作用域。content hash可同时记录actual identity用于缓存与审计;UI不暴露宿主绝对路径。

若链接形成循环,realpath抛ELOOP,不能当ENOENT。Runtime停止并告诉用户修复仓库指令。忽略会导致局部约束缺失,Agent继续写入可能违反团队规则。错误容忍必须基于明确可安全缺省的情况。

预算例子root内容三字节、web三字节,maxBytes五。读root used三,读web后六超限,整次reject,不返回只有root。若只截后层,恰好丢掉更具体规则;若只保留后层,丢组织全局约束。完整性比勉强生成Prompt更重要。

恰好上限应通过,判断使用>而非>=。中文中文字符串length二,UTF-8六字节,maxBytes五必须拒绝。测试用Buffer.byteLength算期望,证明预算单位而非仅结果。

maxBytes零且无指令可返回空;存在任何非空指令则超限。负数、小数、NaN和Infinity拒绝为配置错误,不做磁盘读取。数字边界与第10课Context Budget保持一致。

AGENTS空文件占零字节但仍可作为entry,来源表示显式规则文件存在。产品可跳过空内容减少噪声,却要定义是否影响局部覆盖。课程读取并返回,行为可预测。

读取顺序中先realpath再read actual,若原逻辑文件被替换,actual字符串仍指向当时解析目标路径,但目标内容也可能变化。打开句柄后fstat/read能减少竞态;在用户可编辑仓库里,Prompt组装完成后用content hash绑定计划,变化则重新加载。

指令优先级不是简单后文覆盖所有前文。根“禁止网络”属于组织安全时,局部不能改;根“默认pnpm test”可被web“pnpm --filter web test”细化。合并器按规则类型与权威层级处理,Loader只提供来源有序列表,不自行解释自然语言冲突。

如果AGENTS内容要求修改targetPaths之外文件,模型可以提出计划扩展,但写权限仍受allowedPaths与用户审批。仓库指令不拥有扩大作用域能力。将指令与权限分层,是抵抗Prompt injection的关键。

provenance hash基于loader schema、realRoot identity、规范target集合、entries逻辑path、content hash和顺序,不含绝对路径或时间。相同输入得到相同hash;文件内容或target变化使旧计划失效。审批与Checkpoint可引用它。

缓存读取内容要验证size、mtime与inode,但mtime可能粒度碰撞,关键操作用hash。缓存仅优化I/O,总字节仍按实际content计算,不能因共享祖先命中缓存漏计或重复计费。一次entries每文件只计一次。

大monorepo目标路径很深,祖先数通常几十,复杂度可控。防御恶意超深字符串,限制target长度与segment数量;目录名控制字符拒绝或安全展示。每个target也受数量上限,避免一万目标形成大量stat。

多target跨不同子项目时,Context可以按任务拆分而非全部合并。计划Todo绑定path scope,执行每项加载对应指令,减少冲突和token。批量原子补丁涉及所有路径时才加载联合集合,并显式呈现分支规则。

指令文件变化应写入Trace事件 instructions_loaded,含paths、hash、bytes与target摘要。不要持久化全文到远程日志;Checkpoint保存provenance hash,resume时重新加载并比较,变化则暂停让模型重新规划。

resume不能直接信任旧Prompt缓存。仓库可能在进程停止期间更新AGENTS规则;恢复任务前重新计算,若hash不同,当前in_progress任务block为instructions_changed,用户或模型审查新规则。状态正确恢复与环境重新验证同等重要。

安全测试还要创建名称前缀兄弟root、嵌套目录symlink与目标路径symlink。目标文件可不存在,但祖先目录若链接出root,词法chain会候选外部目录;真实指令检查能阻止文件,正式target路径解析也应验证真实祖先,防止后续编辑越界。

最终验收让模型只修改web,检查Prompt有root/web且无api;同时修改web/api,检查root一次与两局部;把web规则链接外部,整个操作零内容返回;把中文规则置于精确预算边界。相关性、顺序、安全、预算四个性质都有直接证据。

还要核对页面展示的顺序与实际Prompt一致。若后端entries正确,前端按path重新排序,也会破坏局部覆盖;UI应按数组顺序显示层级编号,允许展开来源与hash,但不重排。机器与人必须看到同一指令栈。

对冲突做静态诊断时,不要试图完全理解自然语言。可以识别结构化frontmatter中的testCommand、language或deny能力,同层不同值报告;普通正文仍原样交给模型并标注来源。过度语义合并可能误删重要细节,保留证据比假装解决所有冲突可靠。

组织级指令不应存放在可由仓库修改的AGENTS.md中。Runtime先注入受信系统与组织规则,再加载仓库根/局部建议;Loader的realRoot边界保护秘密读取,不提供内容可信度。每段元数据明确authority,Prompt编排器执行不可覆盖关系。

在依赖仓库或子模块中,目标祖先可能跨多个Git根。默认以任务workspace root为唯一边界,子模块内AGENTS可作为普通文件加载;若子模块被视为独立信任域,创建嵌套Loader并单独审批。不能让.git信息自动扩大文件访问。

文件watch事件可能在加载期间连续触发。使用debounce后重新构建完整entries,不在旧数组原地替换单段;生成新的provenance hash并原子切换Context。正在执行的写操作绑定旧hash,提交前发现变化则暂停,而不是半途混用新旧规则。

测试中外部secret内容必须使用无害固定字符串,并确保失败输出不打印它。断言不仅reject,还检查捕获message和日志不含secret。安全门禁要证明“没有读取/泄露”,不能只证明最终状态为错误。

最终性能测试在十万文件但目标深度十的仓库中,Loader访问的候选数量应约等于祖先层级,不随总文件数增长。若实现意外glob,全仓规模会立刻暴露。复杂度本身也是相关性设计的可验证结果。

上线报告同时证明指令集合完整、来源有序、内容未越界、总字节受限,并且任何规则变化都会使旧计划失效。

规则来源必须始终可追溯。

下一课衔接

Instruction Loader回答“当前目标要遵守哪些规则”,还没决定模型具体看哪些代码。下一课Repo Context Builder把失败文件置顶、引用来源优先于普通搜索,按行范围去重重叠候选,并在字符预算内保留完整片段和选择reason,形成可解释上下文。

  • Scoped configuration 与层级覆盖。
  • Symlink attack 和 canonical path。
  • Prompt provenance 与来源标注。

从零实现 Mini Code Agent Runtime