读完这篇你能:分清「子 agent」和「多 agent 协作」是两件事;知道一个子 agent 提供方要满足什么契约、能力又是怎么被发现和校验的;会先看清手上这台机器到底能接什么活,再决定委派。
前置:25-插件的状态、密钥与配置存储。约 35 分钟。
示例代码:dsh-subagent-survey
DSH 版本:0.2.0-rc.2
任务太大,一个上下文装不下;或者有些活纯属跑腿(翻文件、查日志),不该占着主对话的注意力。这两种需求指向的是同一套机制的两个面。
目标
- 分清子 agent(一次委派)与多 agent 协作(一个团队)各自的模型。
- 理解「能力」为什么要在请求之前校验,以及两类能力为什么用两种发现方式。
- 会读一个提供方的能力矩阵,据此决定委派与否。
DSH 设计
先对齐两个词。DSH 里一个 agent(智能体)就是一个正在独立干活的 AI 实例:有自己的会话、自己的工具,一轮一轮地调模型。子 agent(subagent)是由一个 agent 派生出来的另一个 agent:把一件事交出去,拿回结果。
「交出去」这个动作没有焊死在对话主循环里,而是被做成一条可替换的能力缝。能力缝(seam)是 DSH 对一类可替换能力的称呼,一条缝有三种角色:一份接口声明(服务定义)、实现它的提供方、使用它的消费方(通常是暴露给模型的工具)。执行 bash 命令是一条缝,委派子 agent 也是一条缝,对话主循环本身不认识它们,换掉提供方就换掉了能力。与 bash 不同的是,委派目标的形态千差万别:在进程内新建一个、带着父对话的历史建一个、把一个轮次委派给另一个产品。所以这条缝允许同一时刻有多个提供方按名字共存,要用哪个点哪个。提供方的契约也很小:一个名字、一份能力声明、一个启动方法,外加一个可选的「可继续创建」方法。
缝可以换,但换错了不能悄悄凑合。「失败要响、绝不静默降级」是跨缝的总规则,能力校验是它在委派之前的落点。每个提供方用五个布尔标记公布自己支持哪些启动时功能:换模型路由、结构化输出、委派深度上限、工具过滤、人设。服务在任何一次委派真正开始之前,就把请求对照这些标记检查一遍。比如请求里要求「把输出限制成某个 JSON 结构」,而提供方的结构化输出标记是否,这次委派就当场被明确拒绝,绝不会先接受再悄悄忽略。另一类「可继续」子 agent——建好后能被反复唤醒接着聊的那种——不走这五个标记:提供方有没有实现那个可选方法,就是全部区分。方法在,能力就在。
多 agent 协作不是另一条机制,而是垫在可继续子 agent 之上的一层。DSH 里实验性的 agent team(一个领头 agent 带几个成员 agent 一起干活的形态),复用的正是可继续子 agent。团队的全部共享状态——成员名单、任务板、相互发消息的邮箱——都只写在领头者那一个会话的日志里。会话日志(session log)是一个会话发生过的所有事的只增记录,DSH 拿它当唯一真相:运行时要展示的团队状态,是把这段日志回放折出来的,没有第二份账本。
委派解决「交给谁」,协作解决「怎么不撞车」;而后者完全建立在前者造出的那种子 agent 之上。
子 agent 与多 agent
这两件事经常被混着说,但它们的组成完全不同。
子 agent 是一个委派通道:把一件事交给另一个 agent 去做,拿回结果。它的核心问题是「交给谁、能带什么条件去」。
多 agent 协作是一套共享状态:几个 agent 一起看同一块任务板、互相发消息。它的核心问题是「谁在做什么、怎么不撞车」。
本篇先讲透前者(它是后者的地基),再看后者。
两类能力,两种发现方式
这是这套机制里最值得先记住的一点。
提供方(provider)通过一个静态描述符公布它的启动时功能。服务会在单次 run 存在之前就检查一遍——如果请求依赖了提供方不具备的功能,会被明确拒绝,绝不会”先接受再静默忽略”。这是那个”失败要响”的规则在子 agent 这条路上的落点。
五个 flag 一一对应启动请求里的可选项:
| flag | 对应请求里的 |
|---|---|
agentOptions |
Agent 提供方、模型、推理强度、token 覆盖 |
outputSchema |
结构化输出 schema |
depthLimit |
委派深度上限 |
toolFilter |
工具过滤器 |
persona |
人设 |
但可继续子 agent 不走这套。它由继续执行管理器自己组合,因此只由一个可选方法把关:
prepareContinuable?(request: ContinuableCreateRequest): Promise<ContinuableCreateSpec>
方法存在即能力。 服务在提供方没有这个方法时拒绝可继续的启动;有它的提供方仍然可以服务普通的一次性委派。
这个设计值得停一下:为什么不用第六个 flag?
因为可继续子 agent 的组合归继续执行管理器,提供方在这件事上唯一的参与就是交出”这个孩子的初始状态要不要带父历史”。给一个只参与这么一点的路径加 flag,等于让描述符去描述它并不拥有的行为——不如让方法的类型本身说话。
提供方契约
一个提供方是一个具名的传输层,多个可以共存。它要交出六个成员:
name:注册名,比如spawn、fork、acp。capabilities:上面那五个 flag。inheritsParentContext:子 agent 能不能看到父的已完成轮次前缀。agentRouteDefaults?:提供方自有的默认提供方 / 模型路由,可选。start(request):建立一个一次性子 agent,发布之后把句柄交回来。prepareContinuable?(request):可选,交出可继续子 agent 的创建输入。
关于 inheritsParentContext,有一个非常容易读错的地方,它的定义里专门点了出来:
它只描述对话种子注入(
fork为 true;spawn和acp为 false),使消费方能生成准确的面向模型的措辞,而并不暗示继承了工具、服务或权限。
也就是说这个字段是给模型看的描述用的,不是权限声明。子 agent 拿到的是一个新的扁平作用域,不是父级注册的副本。
start() 的时序契约也值得记:服务已经校验过能力、也解析好了描述符,所以提供方在返回之前拥有设置过程,并且要在拒绝之前把自己没发布出去的部分清干净;发布之后所有权就转移了,后面无论是轮次失败还是基础设施故障,都通过返回的那个 run 结算。不同的启动可以重叠——取消、失败、结果结算和清理各自独立。
委派出去之后
start() 用一个已经发布的 run 兑现:
interface SubagentRun {
readonly id: SessionId
readonly localAgent: Agent | undefined
readonly result: Promise<SubagentResult>
dispose(): Promise<void>
}
三个字段各有讲究:
id:对本地 run 来说,它必须等于发布出去的子会话 id,那个子会话会用parentSession记下父会话;远端提供方则铸一个在父命名空间内唯一的 id。localAgent:进程内的那个孩子;远端 run 是undefined。result:子 agent 自己的失败不会让这个 Promise reject——模型或传输层失败会以stopReason: 'error'兑现,让消费者把它映射成一个出错的工具结果。只有这条缝没法表示成停止原因的基础设施故障才会 reject。
最后一条是有意的:让”这次没干成”和”这条通道坏了”在类型上就分得开。前者是业务结果,后者是异常。
事件方面:服务铸一个唯一的 runId,观察结果,发 subagent/start,然后返回同一个 run。start() 拒绝意味着没发布的资源已经清理、且不会发出生命周期事件对;发布之后的结果失败会结束已经发出的那对事件。成对的 subagent/end 带着同样的标识与最终输出(或基础设施失败)。两个事件都只用于观察,而且会隔离各自监听器的异常。
任务拆解与协作
当一个任务大到需要多个 agent 同时在场,就是另一套东西了:一个隐式的 Root Team。它在 packages/experimental/agent-team/ 下——实验性,这一点要先说在前面。
三块共享状态:
身份与 roster。 TeamId 是带独立品牌的 Root 会话 id。每个成员从 provisioning 开始,只到达一个终态:active 或 failed。运行时的 running / inactive 是另外派生的(provisioning / failed 直接来自持久阶段),绝不会回写那条记录。
持久 mailbox。 Lead 会话先存下完整的排队消息,只有目标的待处理收件条目或已记录的用户消息落盘之后,才写一条独立的确认事件——于是”已排队减去已投递”就构成了恢复用的邮箱。
投递方式是固定的,调用方选不了:running 的目标在最近的步骤边界收到消息,inactive 的目标在已加载时起一个轮次,否则冷恢复。正因为调用方不能选,持久记录里干脆不存调度方式。
共享任务 DAG。 每条任务事件都存完整快照,revision 是 compare-and-set 值、每次变更加一;blockedBy 边必须指向未删除的任务并维持无环。状态里 pending 表示还没开始或已被释放,in_progress 带着 owner,completed 满足阻塞关系,deleted 是保留的 tombstone。
有一个字段名特别容易误读:writeScopes 是规范化的、提示性的路径前缀,不是锁。它是给人和模型看”这几件事可能会撞”的警告,不提供任何互斥保证。视图层会补上 owner 名、就绪状态和写作用域重叠警告,但不会改变持久快照。
回放靠 agentTeam 这个 Session 投影:把一个 Root 会话折成 roster、任务板和”已排队减去已投递”的邮箱。它按 TeamId 挑记录,所以普通 fork 继承来的事件保留祖先 id、不会进到新 Root 的状态里。顺序和时间继续由会话事件自己的 seq 与 time 负责,Team 快照不重复存。
侦察当前能力
demo dsh-subagent-survey 不长,但它演示的正是本篇最该带走的那件事:动手之前先看清手上有什么。
export function apply(ctx: Context): void {
ctx.tools.register(defineTool({
name: 'subagent_survey',
description: '列出当前所有子 agent 提供方,以及每个提供方支持的启动时能力。',
parameters: {},
output: {
schema: { type: 'string' as const },
render: (_args: unknown, value: string) => [{ type: 'text' as const, text: value }],
},
execute: () => {
const names = ctx.subagents.list()
if (names.length === 0) return '当前没有任何子 agent 提供方。'
return names.map((name) => describeProvider(ctx, name)).join('\n\n')
},
}))
}
describeProvider 里把两类能力分别用各自的方式读出来:
function describeProvider(ctx: Context, name: string): string {
const provider = ctx.subagents.getProvider(name)
// list() 与 getProvider() 之间可以被别的 fiber 摘掉,所以这里要兜住。
if (provider === undefined) return `${name}: 刚刚被移除`
// capabilities 是五个布尔 flag,一一对应启动请求里的可选项。
const supported = Object.entries(provider.capabilities)
.filter(([, enabled]) => enabled)
.map(([capability]) => capability)
return [
name,
` 启动时能力:${supported.length === 0 ? '(一个都不支持)' : supported.join(', ')}`,
` 继承父对话前缀:${provider.inheritsParentContext ? '是' : '否'}`,
` 可继续子 agent:${provider.prepareContinuable === undefined ? '不支持' : '支持'}`,
].join('\n')
}
注意最后两行:inheritsParentContext 是一个普通的布尔读法,而”支持不支持可继续”是问方法在不在。这两行并排放在输出里,正好把”两种发现方式”变得可见。
那个 undefined 兜底也不是摆设:摘掉提供方会阻止新的启动,但不会撤销已经接受的 run,而 list() 和 getProvider() 之间确实隔着一次调用。
验证
cd code/dsh_plugin/dsh-subagent-survey && ./install.sh
三步:
# 1. 配置对不对
dsh --profile test --dump-config | grep -A3 'id: dsh-subagent-survey'
# 2. 看能力矩阵
dsh --profile test "调 subagent_survey,把结果原样贴给我"
# 3. 把某个提供方停掉,再跑一次
# 在 profile 的 cordis.patch.yml 里给它加 disabled: true,重启
dsh --profile test "再跑一次 subagent_survey"
第二步你应当看到几个提供方各一行,能力各不相同——这正是”必须在委派前查”的理由:同一个请求发给不同的提供方,有的接受、有的直接拒。第三步那个提供方从矩阵里消失,而之前已经跑起来的 run 不受影响。
注意事项
inheritsParentContext不是权限声明。 它只说明子 agent 能不能看到父的已完成轮次前缀,是给模型措辞用的。工具、服务、权限都不在它描述的范围里。- 子 agent 拿到的是新的扁平作用域。 它不继承父级的注册,别假设子 agent 能看到父装的东西。
- 能力在请求之前校验。 依赖了提供方不具备的能力会当场被拒(
UNSUPPORTED_CAPABILITY),不会静默降级——所以先查再加条件,比先试再兜错更省事。 result不因模型失败而 reject。 子 agent 自己的失败是stopReason: 'error'的正常兑现;只有基础设施故障才 reject。消费方要分别处理这两条路。- 摘掉提供方不撤销已接受的 run。 它只阻止新的启动。要停正在跑的,用
interrupt;要按父级整片收,用两个drain*方法。 start()返回之前,清理是提供方的事。 拒绝时必须把自己没发布的部分收拾干净,否则会留下孤儿资源。- agent team 是实验性的。 它在
packages/experimental/下,契约还会变,别把它当成稳定地基来盖长期的东西。 writeScopes不是锁。 它是提示性的路径前缀,用来提醒”这几件事可能撞车”,不提供互斥。要真正防撞,得自己在任务编排里串行化。