DeepAgents(十一):子 Agent 为什么不能继续往下派
子 Agent 为什么不能继续往下派
《委派用 isolated 还是 fork》把 2 种模式的开局摊开对照,末尾留了一句:isolated 子手上没有 task,fork 子有,但一调就被拒。
这 2 种「不能派」看着像同一件事,机制其实完全不同。我把这 2 条路都走到头,看挡在中间的是什么。
子 Agent 不能继续往下派,isolated 子和 fork 子走的路不一样:前者从头就没拿到派活的那件工具,后者拿到了,一用就被挡回来。挡它的不是一句提示词,是子自己 state 里一个布尔标记。
全程不用 API key,读完约 5 分钟。
一、mode 那一格,能填的就那么多
装配期就把口子收紧了。我试了 7 个值:
写在 mode 里的 |
结果 |
|---|---|
| 不写这一格 | 通过 |
isolated |
通过 |
fork |
通过 |
handoff |
通过 |
forked |
被挡 |
shared |
被挡 |
inherit |
被挡 |
被挡的时候报的是这一句:
SubAgent 'a1' has invalid mode 'forked'; expected 'isolated' or 'fork'
handoff 通过得有点意外。实测它跟 isolated 是一回事:子绑到 7 个工具,task 不在里面。源码里写着它是 isolated 的旧名字,留给老代码用。
fork 还多 2 条限制。子 Agent 自己不能写 skills,报错说父的技能会继承过去。子 Agent 之间也不能重名,因为模型是拿名字挑人的。
还有一条提醒。只挂一个 fork 子,装配时就会收到 beta 告警,说这个特性还在改,接口可能变。
二、两条路,差别在子 Agent 手上有没有那件工具
子 Agent 默认有 7 个工具,都是文件系统那套:
lsread_filewrite_fileedit_filedeleteglobgrep
挂 isolated 子,它拿到 7 个,task 根本不在列表里。挂 fork 子,它拿到 8 个,多的那个就是 task。
顺序也值得看一眼。父自己的工具面是前面 7 个再加一个 task,fork 子那 8 个,前 7 个位置跟父一模一样。
这里有一处不对称。isolated 子手上那 7 个,就是你在规格里写的那几个,框架一个不多给。fork 子那个 task 是框架主动插进去的,插在文件系统工具后面,位置跟父对齐。
isolated 子 |
fork 子 |
|
|---|---|---|
| 手上工具 | 7 个 | 8 个 |
里面有 task 吗 |
没有 | 有 |
| 想派活会怎样 | 连调用入口都没有 | 拿回一句拒绝 |
isolated 子这条路的尽头是「没有」。它连自己不能派活都不知道,因为那个工具的名字从来没出现在它的选项里。
三、为什么不干脆把工具拿走
fork 子明明派不出去,为什么还要把 task 塞给它,再在调用那一刻拦一下。
源码给的答案藏在一句注释里:拒绝发生在调用那一刻,工具照样让它拿着。理由是 fork 子要跟父保持同一副面孔。
理由拆开有 3 条。父的 task 工具紧跟在文件系统工具后面,fork 子也要插在同一个位置,因为测试断言两边的工具顺序必须一致。
fork 子跑的图形态跟父一样,私有通道才能原样带过去。task 工具的描述还要逐字相同,父和子才能命中同一份缓存。
我实测对照了两边的工具面顺序,前 7 个位置确实一一对应,task 落在同一位。
把工具拿走会同时破掉这 3 条。留工具、调用时拒绝,是能同时兼顾的做法。
四、那句拒绝长什么样
fork 子真去调 task,拿回来的是 123 个字符:
You are a subagent and cannot delegate to another subagent. Complete this task yourself instead of calling this tool again.
它不是异常。图不会断,子那一轮照常往下走。我这边跑到第 3 轮、消息涨到 9 条,全程没抛东西。
模型收到的身份是工具回执,不是报错。它读到的意思很直白:这条路不通,活还得你自己干。
连试 2 次也一样。子收到 2 条工具回执,逐字符等于源码里那句话。这道闸读的是一个布尔标记,标记不动,试几次都是同一句。
还有一处细节:这道检查排在校验前面。我让子去调一个根本不存在的子 Agent,拿回来的仍是递归拒绝,没提「不存在」这回事。
最后,整件事只发生在子内部。父这边那句拒绝出现 0 次,父的收尾照常输出,完全不知道子被拦过。
五、挡它的那个标记
调用时怎么知道自己是不是子,靠一个布尔标记。它待在子自己的 state 里,键名叫 _deepagents_forked_context。
我把 3 种子 Agent 的 state 键面都读出来对照:
| 子 Agent 形态 | state 里的键 | 带标记吗 |
|---|---|---|
isolated 子 |
files、messages |
不带 |
声明式 fork 子 |
_deepagents_forked_context、files、messages |
带,值是 True |
runnable 形态的 fork 子 |
files、messages |
⚠️ 不带 |
标记的 schema 声明也有讲究。官方给它标的是「保留输入、省略输出」。保留输入,父才能把 True 塞进子的初始 state;省略输出,子跑完不会把它带回父这边。
塞标记这件事发生在子开局之前。所以子第一次被模型调用的时候,这个键已经在它的 state 里等着了。
还有一个容易漏的细节。给 fork 子装 task 工具的那个中间件,顺手把这个键声明成了真通道。源码注释解释了原因:没声明的话它只是个普通字段,runtime.state.get() 读不到,那道闸就形同虚设。
拦人的活其实是两道。子 Agent 开局的第一条人类消息前面被接了一段 567 个字符的预告,里面有一句写着「你要是想再派一层,会被拒绝,直接把活干完」。提示词先说一遍,运行时再拦一次。
至于父那边,task 描述里 fork 子那行尾多的 86 个字符,只提了「它继承你的完整对话」,一个字没提它派不出去。
六、一处缺口
上一节那张表最后一行,值得单独说。
fork 子有 2 种写法。声明式的那种给它一份规格,另一种是 runnable 形态,你直接塞一个编好的 Agent 进去。
# 声明式
{"name": "fk1", "mode": "fork"}
# runnable 形态
{"name": "cfk", "mode": "fork",
"runnable": my_agent}
我让 runnable 形态的 fork 子去派活。它内部那个 task 工具真的派出去了,孙辈跑了起来,父这边一句拒绝都没见到。同样一条链换成声明式的 fork 子,孙辈压根没起来。
差别就在那个标记。声明式那条分支会给子写上 True,runnable 那条分支只做键的过滤、不写标记。
声明式 fork 子 |
runnable 形态的 fork 子 |
|
|---|---|---|
| 子 state 里的标记 | 写上了 True | 没有 |
调 task 会怎样 |
被拒 | 放行,孙辈真的跑起来 |
| 边界谁来守 | 框架 | 你自己 |
这个缺口有它的道理。runnable 是你自己编好塞进去的,框架不知道里面装了什么工具,也帮不上这个忙。
非要走这条路,边界就自己兜:塞进去的那个 Agent 别带 task,或者自己在里面再放一道检查。
七、怎么用
3 条记下来就够:
- 子 Agent 默认是
isolated,它不能继续往派,这是设计好的,不属于缺功能。 - 想让子 Agent 接着你的对话往下走,用声明式
fork。它手上有task,调用那一刻会被拒。 - 走
runnable形态的时候别指望这道闸,边界得自己守。
回头看不难。防递归这件事,做法是先让 fork 子跟父长得一模一样,再在一个布尔标记上做文章。子 Agent 能看见那件工具,也能伸手,伸手那一刻被按住。