派我来的,不是我:别让一个 ID 扮演整个 Agent

一次多代理运行时的身份拆分:我是谁、替谁工作、谁能收到消息,是三个问题。
1. 子任务怎么把自己认成了上司⌗
设想一个普通的任务树。对话 C 派出任务 A,A 再派出任务 B:
对话 C
└─ 任务 A
└─ 任务 B
B 有自己的执行线程,但它也需要知道工作归谁:确认流程、附件投递、命令来源以及执行机器的关联,都不能因为开了一个子任务就失去归属。
这次修复之前,运行时用 channelKey 携带这个归属。在任务回合里,它指向派出任务的线程,而不是任务自己。这本来有明确用途。
问题出在两处消费者上:
- 提示词把
channelKey打印成Current channel。 - 唤醒工具把目标与
channelKey比较,相等就报「不能唤醒自己」。
于是 B 看到的“当前线程”成了 A。它试图通知 A,工具又说:你不能通知你自己。
不是模型突然产生了身份危机。是运行时先给它发了一张错误的工牌,再根据同一张工牌拒绝它。
一个字段可以存着完全正确的值,却在被赋予错误含义之后制造故障。
2. 两个身份,不能靠改名糊过去⌗
最直接的修法似乎是:把任务的 channelKey 全部改成任务自己的 ID。
但这样会把原有的归属语义一起改掉。原本应该归属发起方的工具行为,也可能跟着换地址。修复通信判断,不应该顺便重写资源归属。
这次改动采用了更小的切口:保留 channelKey,另加 threadId。
| 问题 | 字段 | 普通对话回合 | 后台任务回合 |
|---|---|---|---|
| 这次执行自身属于哪个线程? | threadId | 当前对话 | 任务自己的线程 |
| 这次执行服务于谁、归属于谁? | channelKey | 当前对话 | 派出它的线程 |
普通对话里,两者通常相同。因此,只有单层对话时,很容易误以为它们本来就是一回事。嵌套任务把这份巧合拆穿了。
这里的 owner 也不是“永远追溯到最顶层用户对话”。在上述任务树中,B 的 owner 是 A。它表达的是直接派出关系,不是一个无条件向根节点归并的地址。
更一般地说,执行身份回答“当前是谁在行动”;归属身份回答“这次行动挂在哪条关系上”。二者可能重合,但不能以重合为定义。
3. 提示词和工具,必须拿同一张地图⌗
只改工具判断还不够。如果提示词继续说“你就是 A”,模型仍会按错误的角色关系规划动作。
新实现把身份文本集中到 turnIdentitySection。普通对话保留 Current channel;任务回合则分别写:
Current task thread: <任务自己的线程>
Owning thread: <派出任务的线程>
随后明确说明:本回合运行在任务自己的线程里,owner 是派出它的另一个 agent。
这不是给提示词多加一句“请注意”。它改变了运行时向模型暴露的事实结构。
同一个回合解析出的自身线程 ID,也被传入工具上下文。唤醒工具在 worker 场景中优先读取 threadId,没有时再从当前 session 查询所属线程;非 worker 场景仍使用当前对话身份。
特别值得注意的是,任务自身线程未解析出来时,身份文本不会把 owner 冒充成自身。它仍说明归属关系,但省去无法确定的自身 ID。工具侧也没有把 owner 当作 worker 自身身份的最后兜底。
这比“总得给一个值吧”诚实。错误的确定性往往比缺值更难排查。
还有一条容易漏掉的路径:CLI 工具桥会展开继承父上下文。如果只新增字段、不覆盖继承值,孩子照样会拿着父亲的 threadId。
因此桥接构造器显式写入任务自己的线程 ID,同时保留 owner 的 channelKey。否则原生运行时修好了,换个执行入口,身份又串回去。
提示词、原生工具上下文、CLI 桥不是三个各自正确的实现;它们必须是同一身份模型的三个出口。
4. 不是自己,也不代表能发过去⌗
到这里,还有一个更重要的转折:B 确实不是 A,但 B 仍不能用 wake_channel 唤醒 A。
原因不再是身份,而是路由。该工具的投递路径面向具有入站路由的 channel,任务线程不能通过它接收唤醒。
新实现把两种拒绝分开,先判断是不是自身,再判断目标是不是任务线程:
目标 == 自身线程?
是 → 拒绝自唤醒
否 → 目标是任务线程?
是 → TASK_RESULT_ROUTE
否 → 继续解析对话路由并投递
对调用者而言,两种失败意味着完全不同的下一步:
| 调用关系 | 这次修复后的判断 |
|---|---|
| 任务 B → B 自己 | 仍拒绝自唤醒 |
| 任务 B → 父任务 A | 返回 TASK_RESULT_ROUTE,不是自唤醒错误 |
| 任务 A → 派出它的普通对话 C | 不因 owner 相等而被自唤醒检查拦截;仍需通过后续路由检查 |
| 普通对话 → 任意任务线程 | 同样返回 TASK_RESULT_ROUTE |
| 普通对话 → 自己 | 仍拒绝自唤醒 |
TASK_RESULT_ROUTE 指向既有任务协议:子任务在回合结束后,通过 task-result 自动回传结果;父线程给自己派出的任务补充要求,使用 task_message;等待结果则使用相应等待机制。
方向不能反过来理解。task_message 不是子任务给父任务发消息的通用接口。
这次改动也没有新增子任务向父任务发送中途通知的入站通道。把进度写到共享文件,只是留下可读取的状态,不会凭空唤醒父任务。状态可见、消息可达、结果回传,是不同能力。
因此,修复后的某些调用仍然失败。但它们终于因正确的原因失败,并指向真实存在的协议。对 agent 来说,这是实质改善:它不再围绕一个假的身份冲突反复换参数。
5. 自身身份改了,发送归属没有改⌗
容易产生的另一个误读是:既然工具已经认识 threadId,所有发件人字段都应该换成它。
源码并没有这么做。
成功投递时的来源标记、返回值中的 from,以及去重键中的来源,仍使用 owner 的 channelKey。这次改动只让“判断自己”使用自身线程,没有把整个投递体系改成任务级寻址。
| 使用位置 | 采用的身份 |
|---|---|
| 自唤醒判断 | 当前执行线程 |
| 成功投递的来源标记 | owner / 服务对象 |
| 去重键的来源部分 | owner / 服务对象 |
任务路由拒绝中的诊断 from | 优先自身线程,并另列 owner |
最后一行也提醒我们:不能看到一个叫 from 的字段,就假定它在所有返回形态中都代表同一种身份。拒绝回执里的诊断信息与成功投递的来源标记,仍需按各自语义读取。
保留原有来源语义的好处,是不必为修正自唤醒检查而扩张通信模型。代价也明确:成功投递的 from 本身不能区分同一 owner 派出的不同任务。
按当前去重实现推导,同一 owner 下的两个任务,如果向同一目标发送完全相同的消息,会共享去重键,并受源码规定的五分钟窗口约束。这个结论来自键的构成,不是并发压测结果。
所以,这不是一次“所有身份都彻底统一”的大重构。它是把原本混淆的两个问题分开,同时有意识地保留已有投递归属。
6. 测试应该证明区别,而不只是证明放行⌗
如果测试只断言“通知父线程不再报错”,就会掩盖任务线程没有入站路由这个事实,甚至诱导实现添加一条不存在的通信能力。
这次新增的 wake-channel 测试检查了更准确的契约:
- 真正的自唤醒仍被拒绝。
- 子任务指向父任务,得到路由拒绝,而不是身份拒绝。
- 子任务指向普通 owner 对话,不被误判为自己。
- 普通对话也不能通过该工具直接唤醒任务线程。
- 工具会读取任务 session 的自身线程,而不是把归属字段当自己。
提示词测试则检查另一半:自身与 owner 分别出现,而且任务提示词不再使用误导性的 Current channel 标记。
CLI 桥的既有测试补上了一个关键断言:归属字段仍是 owner,threadId 已经是任务自己。运行时提示词的集成测试也覆盖了顶层任务与嵌套任务。
这些是本文阅读的测试代码所声明的行为,不是写作期间重新执行测试得到的报告。它们支持的是身份分离、拒绝分类与上下文传递,并不构成跨线程消息端到端可靠投递的证明。
7. ID 不是一张万能通行证⌗
这次改动比“提示词写清楚一点”多做了一步,也比“把自唤醒限制删掉”少走了一大步。
它没有靠模型自行揣摩归属关系,没有为了让一个调用成功而发明新路由,也没有把原来承担资源归属的字段全局改义。
它把三个判断重新排正:
- 身份判断:目标是不是当前执行者自己?
- 归属判断:这次执行服务于哪个线程?
- 能力判断:目标是否能通过这个协议接收消息?
相同的字符串,可能只是三个问题暂时给出了相同答案。等到嵌套、桥接或者后台执行出现,巧合就结束了。
让 agent 正确认识自己,不需要一段更有说服力的人格设定。先别把派它来的线程写成它自己。
源码依据:提交 8c121204 的 src/runtime/turn-identity.ts、src/runtime/turn-runner.ts、src/dispatch/task-runner.ts、src/skills/tool-context.ts、src/skills/types.ts 与 skills/wake-channel/tools.ts;协议说明见 skills/wake-channel/SKILL.md。测试依据为 src/runtime/turn-identity.test.ts、src/runtime/turn-runner.operating-mode.test.ts、src/dispatch/task-runner.test.ts 与 skills/wake-channel/tools.test.ts。