dsh-superagent 是 DeepSeek 团队公开的研究原型:一个只服务单个 GitHub 仓库的主动式编码 agent,以 DeepSeek Harness 的 bundle 形式发布。coding agent 本身不稀缺,它值得读的地方是把“谁可以让 agent 干活、能干什么、出了事怎么恢复”写成了代码。
这份报告基于公开提交 8c32a9d(2026-10-31)逐文件阅读。src 约 15,587 行,包含 60 个测试文件。文中判断都指向具体文件与函数,不引用尚未实现的论文设想。
先给结论:它的新意在治理和编排,包括 Gate + Mandate 的授权模型、follow-up 归属判定、observe 影子决策、事务性 effect 队列,以及 Ticket 不跟随 Session 的状态设计。模型能力、沙箱和多仓库协作不是它的强项。
阅读顺序:运行形态 → 领域模型 → 任务流转 → 授权 → 可靠性 → worker 执行 → Source → 模型接口 → 创新与边界。关键节点给出可复核的代码位置。
代码解析 · dsh-superagent · 提交 8c32a9d · 2026-11-04
主动式 agent 真正难的是授权、边界和恢复:谁可以让它写,写到哪,失败后怎么接着写。能不能写代码反而排在后面。这份报告按这四件事拆 dsh-superagent。
形态:跑在 DSH 里的插件 bundle
dsh-superagent 没有自己的模型、会话或工具运行时。它把所有 @deepseek-ai/dsh-* 声明为 peerDependencies,自己只提供编排逻辑。
| 入口 | 注册方式 | 职责 |
|---|---|---|
| 主插件 | src/index.ts:10 的 apply() | 构造 Runtime,注册 Cordis 服务 orchestrator |
| GitHub Source | src/sources/github.ts:112 | 注册 kind,启动通知与仓库轮询 |
| Feishu Source | src/sources/feishu.ts:132 | 注册 kind,启动事件、历史补收与话题轮询 |
| 面板 | DSH client bundle | 只走 /orchestrator/api/*,不直接 import host |
cordis.patch.yml 还定义了两个 agent preset:worker 用 dsh-superagent-worker,主 Session 用 dsh-superagent-router。前者允许文件、搜索、shell、web、skill、todo 和 subagent;后者去掉写文件、web 和子代理,只保留路由所需的最小工具面。
这意味着它不能单独用 node index.js 跑起来。读它之前要先接受 DSH 的会话、工具、权限和工作区模型。
分层:三条依赖规则
目录分成 core、app、executor、sources、infra、client 六层,但真正重要的是三条规则,而不是目录本身。
| 层 | 放什么 | 依赖约束 |
|---|---|---|
| core | 领域类型、Engine、五个 part、端口 | 不 import 层外代码,不做 I/O |
| app | 组合根、Store、Settings、Prompts、Sessions、Effects、Outbox、HTTP、工具 | 可以依赖 core 与 infra |
| executor | DshExecutor、槽位、DSH 事件处理 | 通过窄接口回调 Engine |
| sources | Source 协议、GitHub、Feishu、通道与读取器 | 不 import src/app |
| client | React 面板、路由、文案 | 只拿 host 侧类型与纯值 |
- Trigger、Thread、Decision、Ticket、Assignment 只由 Engine 写;Source 只写自己的游标和 Thread meta。
- Engine 只把
EngineForSources、EngineForMainSession、EngineForPeople、EngineForSettings、EngineForExecutor这些Pick交给调用方,越权方法在类型层不存在。 core无 I/O,sources不反向依赖app,所以领域规则可以用内存仓储完整测试(src/core/memory.ts)。
依赖规则写在 docs/007-architecture.md:255-274,角色接口在 src/core/engine.ts:261-270,端口在 src/core/ports.ts。
领域模型:五个聚合,一条主链
整个系统只有一条主链:Trigger 进入 Thread,Thread 上的批次进入 Eval,Eval 产出 Decision,Decision 的 accept 产出 Mandate 与 Ticket,Ticket 再绑定 Assignment 交给 Executor。
- 01Trigger不可变事件,幂等 id,
threads[0]是主 Thread - 02Thread时间线 + 订阅集合;同一 Thread 同时最多一个 Eval
- 03Eval一个批次的判断;记录 strength、basis、deadline、primary
- 04Decisionaccept / ignore / stop / unavailable,附理由与审计
- 05Mandate不可变授权:strength、policyVersion、basis、actor、scope
- 06Ticket工作单元;持有 Mandate、Channels、Inputs、Assignments、follows
- 07AssignmentTicket 与一次 Executor 的绑定;pending → running → ended
- 08Report / Replyworker 的进度与结果;主 Session 按规则转成人可见消息
| 概念 | 代码位置 | 不变量 |
|---|---|---|
| Trigger | src/core/model.ts:22-39 | 同一条 Trigger 只能建一个 Ticket |
| Thread | src/core/model.ts:157-182 | 一个 Thread 同时最多一个 Eval |
| Evaluation | src/core/model.ts:136-154 | 候选卡送达后才开始计时 |
| Mandate | src/core/model.ts:126-133 | 创建后不可变;只有 stop / 重校验失败能撤销 |
| Ticket | src/core/model.ts:277-308 | open → ended,可被人的消息 resume |
| Assignment | src/core/model.ts:218-233 | 只有最新一个能是 pending/running |
三个共享谓词把规则写在一处:followupOwners 决定一条人的消息归哪个 Ticket;waitsForPerson 决定是否暂停自动输入;ticketStage 决定面板上的阶段。它们让 Engine 的五个 part 不必各写一套判断。
任务流转:从一条消息到一次交付
任务在每个阶段换一次载体,也换一次决策者。后面所有模块都在实现这张表。
| 阶段 | 载体 | 谁决定 | 产物 |
|---|---|---|---|
| 入站 | Trigger | Source 的 scope() | 入库或 filtered |
| 评估 | Candidate | Gate + 主 Session | Decision |
| 授权 | Decision | Engine 按 Policy 复核 | Mandate + Ticket |
| 执行 | Assignment | Worker Session | Report / PR |
| 交付 | Report / Reply | 主 Session | 人可见消息 |
| 结束 | ticket.ended | Source 事实 / worker / 人 | outcome |
名单内的人 @ 了 agent
强度直接是 explicit;主 Session 用 orchestrator_route 在一步内 accept、attach、stop 或 ignore,不需要完整评估。
未被直接点名、但可能沾规则
先由 Gate 判为 inferred;主 Session 调 orchestrator_eval 载入有界线程窗口,必要时 eval_read,最后 eval_decide 提交方向、规则评估与证据。
- Eval 的截止时间从候选卡进入主 Session 开始算;没送达的 Eval 永不过期。
- 过期后最多重排两次,第三次记
unavailable;暂停处理会把截止时间整体后移。 - 已经建过票的 Trigger 不会再次建票;重复的 accept 只返回原来的 Ticket。
- Ticket 结束后,人再在它的 Thread 上说话,会用同一 Mandate 直接 resume,不重新评估。
这条链路分别在 src/core/intake.ts:19-62、src/core/evals.ts:71-156、src/core/tickets.ts:37-56,归属判定在 src/core/model.ts:75-88。
授权:模型提议,宿主复核
这是整个项目最重的部分。授权靠的是 Gate、准入表、Mandate 三层确定性代码,不是写在 prompt 里的“请遵守规则”。模型只能在这个边界内提议。
| 判定顺序 | 结果 |
|---|---|
| 非人:有 actor 且在 Source 的 bot 名单 | 最多 inferred |
| 人:在 allowed 名单且 addressed | explicit |
| 人:按接收 scope 查准入表 cell | explicit / inferred / ignore |
| 落在已有 Ticket 的 Thread 上 | follow-up:名单内 explicit,名单外 inferred |
| 准入 cell | 可选值 | 默认 |
|---|---|---|
| 在名单、未 @ | inferred / ignore | inferred |
| 不在名单、@ 了 | explicit / inferred / ignore | ignore |
| 不在名单、未 @ | inferred / ignore | ignore |
三种 accept mode 决定 inferred 候选的命运:off 不产生候选;observe 照常评估,但只记 would_accept 审计;auto 才真正建票。默认是 observe,也就是先跑影子决策。
Mandate 记录 basis:来源、接收 scope、row、sender、匹配规则、via(addressed / followup / admission / bot)。恢复已结束 Ticket 前必须 revalidate:Source 还要监听、sender 还在名单、admission cell 强度不降、inferred 还需要 auto 且规则仍启用。
一条底线:外部文本、引用和仓库文件都是不可信数据。它们可以影响“这条消息想干什么”,但不能改变“谁可以授权”和“Policy 是什么”。模型判断之后,宿主还要按同一份 Policy 再查一次。
授权代码在 src/policy.ts:189-240 的 gate 与 revalidate,src/policy.ts:40-61 的 assessmentOutcome,src/policy.ts:143-165 的 policyEffects。
运行时:2 秒时钟与持久副作用
Runtime 是组合根,不含领域规则。它按依赖顺序手工构造 Store、Setup、Prompts、Effects、SourceRegistry、Sessions、Settings、DshExecutor、MasterAssessor、Outbox、Engine 和 Passes;因为 Executor 和 Assessor 要回调 Engine,构造时用 getter 而不是直接传对象。
| 机制 | 行为 | 代码 |
|---|---|---|
| Passes | 每 2 秒一次;同一时刻只跑一个 pass,期间的请求合并成下一次 | src/app/passes.ts:28-61 |
| Effects | 事务内 enqueue,提交后 dispatch;同 kind/key 合并,保留队列位置 | src/app/effects.ts:61-66 |
| Store | 单个 SQLite;generated column + partial index 让 pass 只扫活跃行 | src/app/store.ts:26-40 |
| Outbox | 按 Ticket + 会话保序;前面一条连续失败三次才放行 | src/app/outbox.ts:39-51 |
| effect | 处理器 | 并发 | 重试 |
|---|---|---|---|
| assessment | MasterAssessor | 4 | 2s → 60s |
| launch | DshExecutor | 空槽数 | 5s → 300s |
| delivery | DshExecutor | 4 | 2s → 60s |
| relay | Sessions | 1,保序 | 2s → 60s |
| notice | Sessions | 1,保序 | 2s → 60s |
| reply | Outbox | 1 | 5s → 300s |
Later 是这套队列的关键:它表示“现在不能跑”,比如暂停、槽满、Source 未安装。它不计失败,只按初始延迟重排,所以调度器不会把正常的背压当成故障。
副作用先落库、提交后执行,意味着一次崩溃最多留下“待执行”的行,而不是“已经做了但没记账”的状态。这是整个系统能恢复的基础。
执行:一个 Ticket,一个 Session,一个目录
Worker 的代码不在这个仓库里:它运行在 DSH 的 Session 中。宿主负责起 Session、投消息、收报告;调查、实现、测试和提 PR 由 worker 模型完成。
- 01accept 时算执行元数据短名、目录、assignment 文本;只建 pending Assignment,不建目录
- 02launch effect重新校验 Ticket/Assignment → 占槽 → 准备目录(10 分钟上限)
- 03建/恢复 Session
worker-<ticketId>,worker preset,role model,prompt/skills/tools,danger-full-access - 04首条 assignmentassignment ≤1500 字 + 原始 Trigger + 同批 Trigger;之后只发增量
- 05worker turn调查、实现、跑检查;长命令转后台;每条 Input 在下一个 step 可见
- 06reportprogress / waiting / ready / blocked / failed / dropped;问题单独走 attention
- 07结束或继续ready/PR 关闭/dropped/stop 结束;人消息可以 resume
| 资源 | 规则 |
|---|---|
| worker 槽 | maxWorkers 默认 3;人消息/状态请求可多占 1 个;唤醒优先于新 launch |
| 后台工作 | turn 结束后占槽最多 30 分钟;超时杀 shell job,subagent 继续 |
| handoff | ready / attention / blocked / failed 交给人时立刻释放槽 |
| 瞬时错误 | 最多 3 次:launch 按 effect 退避,worker turn 15/30/60 秒续跑 |
| Report | Ticket 变化 |
|---|---|
progress | 只更新摘要,不打断工作 |
waiting | 进入 idle,等下一个 Trigger/Input |
ready | 无 follows → completed;有 PR → 等关闭后按 merged/closed 结束 |
blocked / failed | 进入 person 阶段,自动输入停投 |
dropped | 结束;若跟随的 PR 已关闭,按关闭结果 |
执行代码在 src/executor/dsh.ts:97-111, 247-285, 424-470;槽位在 dsh.ts:165-199;后台与 handoff 在 dsh.ts:588-672。
Source:把外部世界翻译成 Trigger
Source 是可插拔的事件适配器。一个 kind 声明配置 schema、面板字段、监听 scope、通道、线程读取器、Policy 部分、执行变量和健康条目;Registry 负责实例、健康条目独占和 Policy part 合并。
| Source | 入站 | 去重与游标 | 失败处理 |
|---|---|---|---|
| GitHub | 通知、全仓 Issue/PR/评论流、默认分支 CI、Ticket PR 的 CI、已订阅 Thread | 游标回退 5 秒 + 确定性 id;游标不动时 ETag/304 | 单条读取失败留在 Thread;连续 3 次轮询失败报 Source 错误;410 删除,404 重试 |
| Feishu | 长连接事件、历史补收、群名刷新、已订阅话题轮询 | 事件与历史共用一套 Trigger 规则;话题按游标 | 消费者每次失败都报;话题轮询把错误留在 Thread |
已订阅 Thread 在“有活 Ticket,或最后活动/结束在 14 天内”继续轮询(POLL_HORIZON_MS)。超过窗口后只靠普通通知或实时事件,不再主动补历史。
Source 决定推送;阅读仍由 worker 自己完成。worker 可以用 gh / lark-cli 读订阅之外的上下文;订阅只决定哪些消息会主动送到 Ticket。
协议在 src/sources/kind.ts,pollRound 在 src/sources/github.ts:280-296,14 天窗口在 src/sources/poll.ts:6-15。
模型接口:14 + 6 个工具,和一条注意力预算
主 Session 和 worker 各有一套工具和 prompt。宿主用工具面和系统提示词把“路由”和“实现”分开,避免主 Session 变成第二个工程师。
| 角色 | 工具 | 关键约束 |
|---|---|---|
| 主 Session | orchestrator_read / route / eval / eval_read / eval_decide / reply / ticket_retry / request_status / note / trigger_requeue / thread_unsubscribe / settings_save / policy_sync / prompts_save | 不读代码、不查日志、不替 worker 解决 blocker;shell 等待被 noWait 限制 |
| Worker | orchestrator_read / ack / report / thread_unrelated / thread_follow / trigger_handback 加文件、搜索、shell/jobs、web、skill、todo、subagent | 不直接发 IM;Ticket 结束后工具 guard 拒绝调用 |
评估上下文是刻意有界的:Trigger 最多 4,000 字;窗口是首 5 条加 Trigger 两侧各 5 条,近邻 1,200 字、其余 400 字、未展开消息 50 字 stub;要看全文必须用 orchestrator_eval_read 按 id 展开。
Prompt 组装顺序固定:shared → 角色 → 团队三段(Communication / Ticket scope / Delivery requirements)→ subscriptions → replies → dispatch 或 work → admission → handoff → 执行环境 → Policy。Policy 永远在最后,团队只能覆盖内置段落,不能改变生成段。
工具在 src/app/tools/main.ts 与 executor.ts;评估上下文在 src/app/assessment.ts:36-37,183-220;prompt 顺序在 src/app/prompts.ts:102-118;漂移检查在 tests/model-surface.spec.ts。
创新点:哪些值得抄
它的零件大多来自成熟领域:能力授权、工作流引擎、事务性 outbox、事件溯源。真正少见的是组合方式,以及在 agent 场景里对边界的坚持。
- Gate + Mandate + 重校验:把“谁能让 agent 干活”从 prompt 里拿出来,变成可版本化、可审计、可撤销的能力凭证。模型判断之后宿主再复核。
- follow-up 归属判定:一条人消息落在已有 Ticket 的 Thread 上,归该 Ticket 的 worker;主 Session 只能 stop;worker 可以 handback 一次。这解决了多会话 agent 最容易出现的重复派活和抢消息。
- observe 影子决策:inferred 候选照常评估,但结论只记
would_accept,不建票。先用真实流量校准规则,再切 auto。这在 agent 产品里很少见,在运维里很常见。 - 事务性 effect 队列:评估、launch、投递、转发、通知、回复全部先写 SQLite 再执行,每类有独立并发、退避和保序。它让 agent 编排获得接近工作流引擎的可靠性,但实现只有一个数据库文件。
- Ticket 不跟随 Session:Ticket 可以换 Assignment、在 Session 退出后继续 idle、被人的消息用同一 Mandate 唤醒;执行实例和授权对象是分开的。这让恢复语义可以写清楚,而不是靠“会话还在不在”。
如果只抄三样:第一,Gate + Mandate + 重校验;第二,follow-up 归属与 handback;第三,observe + would_accept。其余部分值得学,但属于“照着做就能得到”的工程。
边界:它明确不解决什么
README 自己写明这是研究原型,不是生产软件;论文里有些设计并未在这个版本实现。把边界说清楚,比把它当成平台更重要。
| 边界 | 表现 | 影响 |
|---|---|---|
| 不是安全沙箱 | worker 以 operator 用户运行、danger-full-access、shim 可按绝对路径绕过 | 隔离是默认路径护栏,不是权限边界 |
| 单机单库 | 一个 SQLite 文件、目录锁、单进程 | 没有水平扩展、多仓库、多团队隔离 |
| Source 抽象未完成 | Outbox、links、resources、PR 通知、assessor 读仓库仍直连 GitHub/Feishu | 新增 Source 仍要改少量通用模块 |
| 数据无界增长 | trigger、decision、ended ticket、outbox、prompt 历史都不清理 | 长期运行需要自己加归档或清理 |
| 判断仍靠模型 | 方向是否已定、规则是否匹配,本质是语义判断 | schema 和证据字段只能降低方差,不能证明判断正确 |
还有一个常被忽略的代价:主 Session 被刻意限制成“不读代码”。这让路由更干净,但当评估需要判断技术可行性时,它只能依赖线程上下文和有限的仓库读取,误判会转移到 worker 的 handback 或 dropped。
代码索引:从哪读起
如果只有一小时,按下面顺序读;每个文件都先用 JSDoc 看职责,再跳到核心方法。
- model.ts领域类型与不变量;先读 Trigger、Mandate、Ticket、Assignment。
- engine.ts唯一写入口;看角色接口和每个命令的 JSDoc。
- evals.ts批次、gate、超时、Decision;理解 accept 如何建票。
- tickets.ts生命周期、订阅、Inputs、resume/stop/retry;最密的一块。
- dsh.ts真正把 Ticket 变成 Session 的地方;launch、delivery、槽位、恢复。
- effects.ts持久副作用队列;理解
Later与背压。 - policy.tsgate、准入表、Mandate 重校验、policyEffects。
- sources/kind.tsSource 插件协议;先看字段和可选能力。
- tools/main.ts主 Session 的 14 个工具;对照 prompts/dispatch.md 读。
- prompts/模型实际读到的指令;先读 shared、orchestrator、dispatch、work、admission。
测试也是文档:tests/core-engine.spec.ts 覆盖领域规则,tests/runtime.spec.ts 在模拟 DSH 宿主上跑整套应用,tests/model-surface.spec.ts 检查模型可见面的漂移。
结论:把它当参考架构,不要当平台
dsh-superagent 最值得读的地方,是它把“主动”拆成了可审计的步骤:谁触发、谁授权、谁执行、谁决定结束。它让模型的判断被一层确定性代码包住,而不是去追求更聪明的模型。
它不是可以直接照搬的生产平台:没有真实沙箱,没有多租户,数据也不清理。但作为一份参考架构,它把主动式 agent 最难讲清楚的四件事(授权、归属、恢复、注意力预算)落到了具体文件和函数上。
可复用清单
- 把授权做成 Mandate,而不是 prompt 里的一句话。
- 给每条人消息定义唯一的归属和一次 handback。
- 先跑 observe 影子决策,再放开 auto。
- 所有外部动作先落库,再执行;恢复从表里读。
- 让 Ticket 拥有状态,Session 只是执行实例。