夜雨聆风学习资料网

ARTICLE · 1165114

dsh-agent-teams源码阅读:它把“队长带团队”写成了十件小事

dsh-agent-teams源码阅读:它把“队长带团队”写成了十件小事

之前读了 Claude Code 的源码:Subagent、Fork、Teammate、coordinator,四套协作机制。这篇换个对象,看一个把同一套"队长带团队"模式真正做成产品的开源插件——dsh-agent-teams,跑在 DeepSeek Harness(下称 DSH)上。

它不讲概念,它交付一套能用的东西:装进 DSH,用一句自然语言就能拉起一支团队。当前会话自动成为队长,按角色拉出若干成员,把目标拆成带依赖的任务,成员之间还能互相直接发消息,右上角挂着一个实时活动面板盯着进度。

一、先弄清楚它是什么:不是新运行时,是把现成的接缝拼起来

它本体是一个 DSH 的 bundle 插件,同时扮演两个角色:host 面(Node 侧)注册工具和服务,client 面(浏览器侧)渲染那个活动面板。装完之后,这个 DSH profile 下的任意会话都能用自然语言驱动它。

它的入口文件很能说明设计取向:对模型暴露的接口只有两件事——往全局 tools 注册表里注册十个 agent_teams_* 工具,往系统提示词里插一段"使用协议"。(此外还挂了两条 Web 路由,给活动面板供数据和头像图片,那是浏览器侧的活儿,第七节再讲。)

const toolNames = [   'agent_teams_create',   'agent_teams_add_member',   'agent_teams_remove_member',   'agent_teams_create_task',   'agent_teams_reassign_task',   'agent_teams_claim_task',   'agent_teams_update_task',   'agent_teams_send_message',   'agent_teams_status',   'agent_teams_delete', ].join(', ')

十个工具,就是"队长带团队"的全部动作面。没有第十一个。

官方文档里有一张对照表,把插件的每一处能力都映射到 DSH 已存在的接缝上:

它想做的事
用的现成能力
注册工具
ctx.tools
创建可续聊成员
ctx.subagents.startContinuable()
唤醒某成员
ctx.subagents.followup()
发现成员 / 拿真实活动状态
ctx.subagents.listChildren()
 + ctx.agents
成员空闲后自动派下一单
agent/status
 事件
注入使用协议
ctx.systemPrompt.section()
面板数据 / 图片
Web server 路由注册
状态持久化
文件系统

换句话说,它没有引入 workflow 引擎,也没有自定义一套调度运行时,而是把 DSH 本来就有的"能力接缝"重新组合了一遍。这是我读下来第一个觉得舒服的地方:做基础设施,最先该问的不是"我要加什么",而是"现有的东西能拼出什么"。

二、状态层:一个团队,本质上就是一个目录

插件把团队状态落在磁盘上:

<workspace>/.agent-teams/<teamId>/ ├── team.json            # 团队记录:成员、任务(含依赖)、任务序号 └── inbox/     ├── captain.jsonl    # 队长的邮箱(成员 → 队长)     └── <member>.jsonl   # 每个成员一个邮箱(JSONL)

一个团队就是一个目录、一个 JSON 文件,外加每人一个 JSONL 邮箱。这个做法很朴素,但和 Claude Code 那边"Team = Project = TaskList"的思路是一脉相承的——用磁盘上的目录结构,直接表达组织关系。

朴素归朴素,但底下有两件事做得很细。

第一件,所有写入都是串行的。 团队状态被一个进程内的 promise 链锁住:

export async function withTeamLock<T>(key: string, fn: () => Promise<T>): Promise<T> {   const previous = locks.get(key) ?? Promise.resolve()   let release!: () => void   const gate = new Promise<void>((resolve) => { release = resolve })   locks.set(key, previous.then(() => gate))   await previous   try {     return await fn()   } finally {     release()   } }

读-改-写的整个过程被关在锁里。配套的落盘函数也做成了"临时文件 + rename"的原子替换,防止写到一半崩掉留下半截 JSON。不过这把锁是进程内的——docs/usage.md 的"已知限制"里写得很诚实:多个 DSH 进程同时改同一个团队,不保证一致。

第二件,一个关于文件名的教训,藏在一段注释里。 队友名字会被折叠成合法的路径片段,实现函数叫 sanitizeKey()。它的注释是这么写的:

An ASCII-only whitelist mapped *every* non-Latin name onto one shared fallback, which silently merged their mailboxes and rejected the second such member as a duplicate.

翻过来就是:早期版本用一个纯 ASCII 的字符白名单,结果所有非拉丁名字都被映射到了同一个兜底值——邮箱被静默合并,第二个中文名成员还会被当成重名直接拒绝。所以后来改成了 Unicode 感知的写法,用 \p{L}\p{N} 保留字母和数字(中文、西里尔、希腊字母都能留下),纯 emoji 或标点这种确实没法生成可读 key 的,才退化成一段哈希摘要;过长的名字则截断后补摘要(因为 CJK 在 UTF-8 下每字符占 3 字节,不截会顶到文件系统上限)。

这种注释就是读源码的乐趣所在——它不是在解释代码干了什么,而是在记录代码曾经错过什么。

三、真正的核心:attempt 令牌,解决"迟到写入覆盖新结果"

任务有一套显式状态机:

export const TASK_TRANSITIONS: Readonly<Record<TaskStatus, readonly TaskStatus[]>> = {   pending: ['claimed', 'cancelled'],   claimed: ['in_progress', 'failed', 'cancelled'],   in_progress: ['completed', 'failed', 'cancelled'],   completed: [],   failed: [],   cancelled: [], }

终态没有出边,也就是说 completed/failed/cancelled 之后不可再改。这一点后面会反复用到。

但状态机本身不难,难的是并发。设想一个再常见不过的场景:队长发现某个成员把任务做砸了,于是把任务转派给另一个人,或者干脆自己接手。问题是,那个被撤掉的旧成员,可能还在跑它上一轮的活儿,跑完之后它会调用 agent_teams_update_task 把结果写回去——如果这行写入在转派之后才落地,它就会覆盖掉新成员的结果。

这个插件给出的答案是一套"能力令牌 + 代次"的机制。每个任务上挂着三个字段:

  • attempt
    :单调递增的执行代次;
  • attemptId
    :当前这一次执行的能力凭证,每次开启都是一个新的 UUID;
  • handoffId
     + reassigning:一次还没开始下一轮的转派过程。

转派时,第一个动作不是去中断旧成员,而是先作废旧凭证:

/**  * Revoke the current worker immediately. Clearing its capability makes old  * updates stale; a separate handoff generation serializes async quiescence.  */ export function invalidateTaskAttempt(   task: TeamTask,   nextAssignee?: string,   reassigning = false, ): void {   task.attemptId = undefined   task.handoffId = randomUUID()   task.status = 'pending'   task.assignee = nextAssignee   task.reassigning = reassigning   task.output = undefined   task.updatedAt = Date.now() }

attemptId 一清空,旧成员手里那个凭证就立刻过期了。它之后无论写什么,都会被工具层顶回来——成员更新任务时必须带上当前 attempt_id,对不上就是一行干脆的报错:

stale attempt for task ...: expected the current attempt_id; stop work and request fresh assignment

然后才是"中断旧成员 → 等它真正安静下来 → 落最终状态"这一串异步收敛。整套顺序严格排成:先撤权,再中断,等安静,最后定稿。源码把这条原则写得很直白——转派"revokes the old attempt and waits for that member to quiesce",为的就是 "preventing late results from overwriting the new attempt"(迟到的结果无法覆盖新的 attempt)。

另外队长这边被加了一道额外的规矩:队长想自己接管一个成员名下的任务时,不能直接 update_task,必须先走一次 reassign_task(assignee="captain")。工具描述里写得很清楚:

A captain must use reassign_task(assignee="captain") before updating member-owned work.

理由是同一个:"我接手"这个动作本身,也必须是一次正式的、会作废旧凭证的所有权变更,不能靠直接改状态绕过去。否则"队长正在写"和"旧成员以为自己在写"就会同时成立。

一句话总结这一节:它用一枚会过期的令牌,替代了本该有的分布式锁。 在这套系统里,谁在写不重要,谁手里攥着当前那次 attempt 的凭证才重要。

四、调度器:把"轮询"换成了"事件驱动"

Claude Code 的 teammates 是怎么拿活的?每轮结束之后,去轮询那张共享任务表。 也就是要有那么一个还活着的轮次,不停地问"有没有我能干的事"。

DSH 这边不太一样:它的可续聊代理会显式地抛出 idle / running 状态边。于是调度器的注释开门见山——

Claude Code teammates keep polling the shared task list after a turn. DSH continuable agents instead expose explicit idle/running edges, so this scheduler closes the same loop without keeping a polling turn alive: every idle edge and every task-graph mutation attempts one atomic claim and wakes the selected durable member.

翻译过来就是:它不需要靠一个还活着的轮次去轮询,而是监听事件。 每一次"某成员变空闲"和每一次"任务图发生变动",都会触发一次原子领取,然后唤醒被选中的那个持久化成员。没有常驻轮询,就没有那笔常驻的开销。

调度器监听的就是一条事件:

ctx.on('agent/status', ({ agent, status }) => {   void syncMemberStatus(agent, status).catch((error: unknown) => {     ctx.logger.warn(`agent-teams: member status scheduling failed for ${agent.id}: ${String(error)}`)   }) })

同步函数在成员真的 idle 之后回写状态,并顺手踢一次 kickMember。

但这里有个很现实的坑,也是让人觉得设计得最有意思的一处。一个成员在磁盘上可能处于"空闲",可它手上却还攥着一个 claimed / in_progress 的任务——这通常意味着它那一轮的活儿丢了:模型提前停了、中断结算得不对、进程重启了。天真的做法是把它当成"忙",然后这个任务就永远卡在那儿。插件选了另一条路:

An idle/ready member that still owns an open task lost the turn that was executing it (model stopped early, interrupt settlement, or process restart). Retry that task with a fresh capability instead of permanently treating the durable claim as "busy".

撤销旧凭证、开一代新的 attempt、把同一个成员重新唤醒。 与其相信那个"忙"的状态,不如相信事实。这一点和上一节的令牌机制是同一根骨头——用代次把"上一次执行"和"这一次执行"彻底切开。

派发的收尾也做得很克制。如果这次唤醒没被成员接受,它只回滚"自己那一次"派发——回滚前先比一遍 attemptId,只有还是自己刚发出去的那一枚才退回 pending;要是期间队长已经转派过,凭证对不上,那就让新状态赢,它自己安静退出。

Roll back only our exact failed dispatch. A concurrent captain handoff has already changed the capability and wins.

还有一个兜底值得记一笔:如果消息没法实时投递(成员那会儿不可达),它不会丢,而是持久留在邮箱里,等下一次状态边界重投。为了防止"实时投递"和"兜底投递"两条路径抢同一条消息,每条消息上有一个 60 秒的投递租约(deliveryClaimedAt),谁先租到谁投,投成功才真正标记已读。

五、通信:文件系统邮箱,成员可以绕过队长

团队里所有消息都写进对方的 JSONL 邮箱,追加式、原子写。一个成员想找另一个成员,直接用 agent_teams_send_message,消息直达对方邮箱并唤醒对方,不需要队长中转。

这跟 Claude Code 的 mailbox 模型是一致的。但插件在此之上还接了一条实时通道:队长在线的时候,成员发给队长的汇报不只是躺进邮箱,还会走 Agent.steer() 插到队长最近的一个模型步里——

Agent.steer() targets the next step while the captain is running, wakes a new turn when it is idle... This prevents reports from waiting behind the captain's entire orchestration turn.

不这样做的话,成员的报告得排队等队长把整轮编排跑完才能被看到。而给成员发消息,用的是 followup(),直接把内容变成成员的下一轮。

有一处很小的约束,特别关键,消息里有 from 字段,而代码只允许它等于调用者自己的身份:

from may only be the caller's own identity: impersonating another member (or the captain) would poison the mailbox and event records.

不允许冒名。 在一个成员之间可以自由发消息的系统里,这是必须堵死的口子——否则谁都能以别人的名义往邮箱里塞话,整套审计记录当场作废。

消息对象上还有一整套生命周期字段:deliveryClaimedAt(投递租约)、deliveredAt(已被实时收件箱接收)、readAt(已被消费)。一条消息的状态是能被一步步追出来的。

六、边界:成员能做什么,不能做什么

前面几节讲的全是"怎么协作",这一节讲"怎么不让它乱来"。

成员的运行形态是队长的可续聊子代理(continuable subagent)——它的会话跨轮次、跨进程重启都还在。创建时它会收到一份自己的 persona(替换掉部署的默认人格),里面第一句话就交代了身份,后面跟着七条工作规则。摘最后两条:

6. After your turn becomes idle, the shared task scheduler may assign your next ready task automatically. Never claim a second task while you still own unfinished work.<br/>7. You are a worker: do not create or delete teams, reassign tasks, or add/remove members — that is the captain's job.

这两条各自对应代码里的一处硬约束。第 6 条对应的是:一个成员不能同时拥有两个未完成任务——领取和转派前都会查一遍,撞上了直接报错。第 7 条对应一份工具黑名单——建队、加人、移除、转派、建任务、删队,这六个队长专属工具,对成员直接隐藏:

const MEMBER_DENIED_TOOLS = [   'agent_teams_create',   'agent_teams_add_member',   'agent_teams_remove_member',   'agent_teams_reassign_task',   'agent_teams_create_task',   'agent_teams_delete', ] as const

顺带一提,这份黑名单比 persona 里那条规矩还多禁了一个 agent_teams_create_task——提示词只说了别建队、别转派,代码干脆把"建任务"也一并收走。再加上配置里的两个默认值(maxMembers: 8 卡住团队规模,memberMaxDepth: 1 只允许成员再往下派一层),成员的权力边界是被一层层收窄出来的。

对照前几篇读过的 Claude Code:Teammate 是"平级同事",能互相通信、有独立身份;coordinator 底下的 worker 是"外包",工具被收窄到只剩干活。这个插件其实是个混合体——成员之间可以像 Teammate 那样直接对话,但工具面被收得像 worker 一样窄。 它要的是"沟通平级化、权限不平级"。

成员用什么模型,也有个很好的产品决策:默认零交互。插件会快照队长当前这一步真正在用的 provider、model 和思考强度,成员后续接着用这份快照。只有用户明确说"后端用 A 模型、前端用 B 模型"这种异构需求时,才会把 provider + model 传下去。绝不会挨个弹窗问你"这个成员用什么模型"。这种"能不问就不问"的克制,在工程里其实是稀缺品。

七、面板:它宁愿相信磁盘,也不相信模型

活动面板是浏览器那一半。数据由 host 一条路由提供:

/plugins/dsh-agent-teams/state

这条路由每次都重新读磁盘上的团队文件,再把实时的子代理活动合并进来。为什么非要这么做?注释里给的理由很实在:

...so the panel always reflects the on-disk state even when a model skipped a tool "ritual" (e.g. not calling update_task on completion).

模型偶尔会忘记走工具"仪式"——活干完了,却没调 update_task 把状态改过来。如果面板相信模型的汇报,它就会撒谎;如果面板相信磁盘,它就永远是实话。所以这条路线的设计原则是:真相源只有一个,就是磁盘。

面板本身是个右上角的浮层(挂 body portal),1 秒轮询一次;展示队长的分段总进度、可折叠的成员树,还有一张用依赖深度分列的紧凑任务 DAG,边是 SVG 三次曲线画的,悬停或键盘聚焦能高亮一条完整的上游-下游链。头像用的是 DeepSeek 小鲸鱼的职业插画,按角色关键词(中英双语正则)匹配——researcher、engineer、qa、security……状态小图还会跟着"工作/空闲/未知"切换,并且老老实实遵循 prefers-reduced-motion。

最后一个细节,回到了团队的生命周期终点。agent_teams_delete 这个工具名字叫"删除",但它实际做的是归档:

Archive, not delete: tasks (with their dependency graph) and the mailboxes stay on disk for later review and dependency rebuilds.

整个团队目录被移进 archive/<teamId>/,任务、依赖图、邮箱全套留档,以后再开会话还能把同一张成员树和 DAG 恢复出来。面板那条路由也留了开关:请求带上 ?archived=1 就改读 archive/ 下的历史团队,而且特意保留已标记 removed 的成员——不然恢复出来的树会缺人。对照 Claude Code 那边"团队文件以前不删会永远留在磁盘上"的教训(那个还挂着 issue 号 gh-32730),这里的答案不是"记得清理",而是"把清理换成归档"——从源头让这件事不再是问题。

写在最后

回头看,这个插件最打动我的不是那个活动面板,也不是小鲸鱼头像,而是三件很"土"的事:

  • 调度不轮询,改写事件。
     一句注释直接承认了它在这一点上和 Claude Code 走了不同的路——不是抄,是看到接缝不一样之后的重新选择。
  • 用一枚会过期的 attempt 令牌,替代分布式锁。
     全部并发问题的答案,收敛成"谁攥着当前凭证,谁才说了算"。
  • 文件名折叠的注释里,写着一整段踩坑史。
     中文名成员被静默合并这种事,只有真跑过、真痛过的人才会留下这样一段话。

前几篇读的是 Claude Code 官方怎么设计多智能体;这篇读的是一个开发者怎么把它落地成一个插件。两边的差距恰好说明了一件事:概念框架谁都能画,真正决定成败的,是"迟到写入不许覆盖新结果""成员空闲了但活丢了怎么办""消息两路投递打架怎么算"这些没人愿意在架构图里画出来的细节。

参考资料

[1] dsh-agent-teams 源码

[2] dsh-agent-teams 官方文档

相关学习资料