把工具清单当权限边界:VS Code 自定义代理的改动约束
我连着三次在提示词里写”只改这两个文件”,代理第三次还是顺手重排了 import。问题不在它不听话:角色描述本身不是权限。VS Code 官方那篇配置指南里有句话戳得很准,工具限制取决于 harness,别以为写一段角色说明就能挡住编辑动作。[3]
自定义代理把这件事落到了文件上。.agent.md 里的 tools 决定这个角色能碰哪些工具,agents 决定它能派哪些子代理,handoffs 决定它把活交给谁。[1] 这些字段比对话里的叮嘱硬,但也只在支持它们的 harness 里才硬。
tools 是白名单,但要看 harness 认不认#
规划代理只配读取和搜索工具,官方给的理由很直接:研究阶段不需要写权限,只读工具能避免误改。[1] 清单里可以写内置工具、工具集、MCP 工具和扩展贡献的工具;想放开整台 MCP 服务器,用 <server name>/* 一次全给。[1]
有个坑:某个工具在当前环境不可用时会被直接忽略,不报错。[1] 清单只表达意图,它不保证那个工具真被用上。想确认就回聊天记录里把工具调用展开看。
harness 的差别更要紧。同一份 agent 文件,Copilot harness 会把 tools 当白名单用,Claude harness 认 Read, Grep, Glob 这种逗号分隔的写法,而 Codex 集成只读角色指令、不把 tools 当准入清单,官方说它是引导而不是强制。[3] 我的做法是分开对待:会强制执行的那一侧把权限收窄,不强制的那一侧就别让代理单独跑。
agents 管住它能派谁#
agents 写名单就只放行名单里的子代理,写 * 放行全部,写空数组 [] 禁止它派任何子代理。[1] 声明了 agents 还得把 agent 工具放进 tools,否则这套白名单没有入口。[1] 反过来,显式列进别人的 agents 名单会覆盖那个代理自己写的 disable-model-invocation: true,官方把它当成有意设计:保护某类代理不被随手调用,同时允许指定的协调者使用它。[2]
user-invocable: false 和 disable-model-invocation: true 是一对开关。前者把它从下拉列表里藏起来、只留给子代理调用,后者相反,只允许人手动触发。[1][2] 写代码的角色设成前者,发版、跑迁移这类不可逆动作设成后者。模型再想自己决定,也点不动第二个按钮。
子代理的默认边界比你以为的紧#
每次调用都是无状态的,主代理没法追问,澄清提问和待办工具在子代理里也不可用,上下文和期望输出必须在一次任务里给全。[2] 把它当一个会写报告的批处理作业更贴切。
递归默认是关的:子代理不能再派子代理,要开 chat.subagents.allowInvocationsFromSubagents,最大嵌套深度是 5。[2] 这条默认值对多数仓库是对的,失控的扇出既烧钱又难复盘。
模型有优先级:调用时显式指定的最优先,其次是代理 model 字段,最后落到主会话的模型;请求的模型不能超过主模型的花费档位,超了子代理不跑,还会告诉你有哪些可用。[2] 我给只读探查配便宜模型,贵档留给实现和验收。
交接按钮是给人留的关口#
handoffs 定义响应结束后的下一步按钮:目标代理、按钮文案、预填提示,以及是否自动提交,send 默认是 false。[1] 默认要人再点一下,工作流才继续。规划到实现、实现到复核这两个衔接点,我宁愿多这一次点击。
代理级的 hooks(预览)只在它激活时运行,需要开 chat.useCustomAgentHooks。[1] 团队共享放进 .github/agents 就行,组织级还要开 github.copilot.chat.organizationCustomAgents.enabled;VS Code 也会读 .claude/agents 里的 Claude 格式文件。[1]
快速上手:三个文件,一条从只读到写入的链#
下面只新建文件,不动既有代码。先建目录:
mkdir -p .github/agents
规划代理只读,也不允许派子代理:
---
name: Planner
description: 只读调查,产出可验收的实现计划
tools: ['read', 'search/codebase', 'search/usages']
agents: []
handoffs:
- label: 开始实现
agent: Implementer
prompt: 按上面的计划实现,只改计划里列出的文件。
send: false
---
只做调查。把计划写到 .agent-notes/<task>/plan.md,每步列出目标文件、风险和验收命令;信息不足就停下,把未知项列出来。
实现代理才拿编辑能力,而且只改计划里列出的文件;复核代理只读,交回结论而不是顺手修:
---
name: Implementer
description: 按计划改代码,只动计划里列出的文件
tools: ['edit', 'read', 'search/codebase', 'read/terminalLastCommand']
agents: []
---
按计划实现。只修改计划列出的文件;要动计划之外的东西时,停下来说明原因,不要自己扩大范围。
---
name: Reviewer
description: 只读复核,对照计划检查 diff 和验收命令
tools: ['read', 'search/codebase']
agents: []
user-invocable: false
---
对照需求和计划复核。运行计划里的验收命令,写出通过、失败和未覆盖项,不要修改任何文件。
接着做三个检查。打开代理下拉确认 Planner 出现了;没出现就去 Configure Custom Agents 看它来自哪个目录,或右键聊天选 Diagnostics。[1] 然后挑一个小任务跑第一次,只给一个目录的边界,让规划代理出完计划就停手,看它的工具调用是不是真的只用了清单里那几个。最后切到实现代理再跑一次,展开 diff 看改动是否落在计划列出的文件里。工具名写错会被静默忽略,[1] 只有在调用记录里才看得出白名单有没有生效。
这批代理如果给团队用,就把工具清单和指令一起送去评审。官方的安全提示写得很直白:在仓库里共享代理时,按最小权限审查工具列表。[1] 评审代理一旦有了写权限,它就不再是评审代理,只是名字好听一点的第二个实现代理。
什么时候不值得这么配#
改一行文案、修一个明确的类型错误,写三个文件比直接改慢得多。值得配角色的是改错代价高的仓库:支付、鉴权、数据迁移,或者要连续跑几个小时的任务。角色文件也不是写完就完,它跟着工具集一起演化,最好当接口来读,工具清单的每次变化都是一次权限变化。
写进文件的权限比对话里的叮嘱可靠,前提是这套 harness 真的执行它。所以配好这三个文件之后,我还会做一件看起来多余的事:用一个越界的小任务去试它一次。能被测出来的边界才算边界,写在文档里的不算。
Sources#
[1] https://code.visualstudio.com/docs/agent-customization/custom-agents — VS Code Docs: Custom agents in VS Code [2] https://code.visualstudio.com/docs/agents/run/subagents — VS Code Docs: Use subagents in Visual Studio Code [3] https://code.visualstudio.com/docs/agents/guides/customize-copilot-guide — VS Code Docs: Configure AI for your codebase