不调模型的部分尽量多
去重、白名单、是否 @、已订阅的 thread,全在 Fast Path 里用规则判完。只有真要判断的消息才唤醒 Router;同一 thread 的后续消息直接推给负责它的 Worker。
路由复现 · 第 1 阶段代码完成,待真实验收
Super-agent 是照 deepseek-ai/dsh-superagent-prototype 复现的 router / worker 多智能体系统:确定性接入、SQLite 状态、订阅直推 Worker、一个 Router 判断去向。它只在沙盒仓里动手,所有对外动作先进 outbox,等人批准。
01是什么
目标只有一件事:在本机照原型复现一套 Super-agent,由确定性接入、SQLite 状态、订阅直推 Worker、本席当 Router 和看门狗五样东西组成。本席自己派 Worker 写底座和沙盒仓的活,不经过工程调度。
去重、白名单、是否 @、已订阅的 thread,全在 Fast Path 里用规则判完。只有真要判断的消息才唤醒 Router;同一 thread 的后续消息直接推给负责它的 Worker。
Router 不是常驻进程,每次被唤醒都从库里重建状态,只看卡片,不读代码、不读 Worker 全文。
PR、issue 评论都写进 outbox,不自动发;用户亲口批了某条才发,带去重标记。
原型是 DSH 插件包,整体跑要装整套运行时。这里照原型逻辑用 Python 标准库 + gh CLI 重写,不引入 Node 依赖。
原型本身只有 GitHub 和飞书。GitLab 原型没有,放到后面再说。
02架构与数据流
所有部件共用一个 SQLite 库 state.sqlite。紫线是消息流向,青线是订阅直推,琥珀线是对外动作;虚线框是计划中、还没写的部件。
disposition 表记录每条消息的去向,第 1 阶段新增。常驻 Python 进程,用 gh 轮询沙盒仓。通知流以 since 游标回退 5 秒,仓库流带 ETag(304 不耗限额),间隔取 60 秒、X-Poll-Interval、配置下限三者的最大值。进程只出站,不监听端口。
同 ID 去重、白名单、是否 @、thread 是否已有订阅。原文一律入 trigger 表,去向写进 disposition。已订阅的直接进该 ticket 的 inbox,不经 Router。
由本席担任,不是常驻进程:每次被唤醒先 sa read tickets --open 重建状态,只看卡片(ID + 160 字预览 + read 路径),路由说明不超过 30 词,不读代码。
一个 ticket 一个后台子任务,各自工作目录,结束时 sa report。中途的新消息,Worker 每步之间读自己的 inbox,或由 Router 续派时带上。写手和审查换不同模型,互不自审。
sa reply 只写入 outbox,状态 pending、approved=false,带 <!-- sa:id --> 去重标记。发送前必须有用户批准,这一闸门是原型没有的。
定时 routine:扫库,报告久未汇报的 ticket 和重复出现的同一错误,只报不自动重来。原型没有独立看门狗,需自写。
03消息路径
以「在 issue 里 @ 机器人,请把 README 标题改成 Hello」为例,对应 stage0 的真实命令输出。
poller 每约 60 秒取一次通知和 issue / 评论流。Trigger ID 是确定性的,形如 github:<repo>:issue:<n>:opened,游标回退 5 秒造成的重复,靠下一步去重吃掉。
先查 ID:已有就返回 duplicate,什么都不写。否则原文入 trigger 表,再判去向:作者是机器人自己 → filtered/self;不在白名单 → filtered/not_allowed。
该 thread 若已有 open 的 ticket 认领,消息直接进它的 inbox,needs_router 为 false,到此结束。done 的 ticket 只有真人消息能重新唤醒;stopped 的不唤醒;[bot] 后缀不算真人。
文中整词 @ 了机器人(大小写不敏感)才进 Router 队列;否则记为 filtered/not_mentioned。离线测试:20 条无关评论加 1 条 @,只入队 1 次。
队首满 2 秒后发一次 POST,内容是 trigger 列表和读取提示。成功才清队列;没有配置 webhook 就保留队列。
Router 先读 open ticket 卡片,再用 sa route 建 ticket 并订阅该 thread。此后同 thread 的追加消息走第 3 步直推,不再经过它。
Worker 在自己的目录里、沙盒仓的独立分支上改。sa report 把进展写回 ticket(截断 600 字),--done 标记完成。
sa reply 把要发的评论排进 outbox,返回 "sent": false。它此刻只是草稿。
只有用户在本席聊天里的原话算批准,别的 agent 转述不算。批准后才发送,并带去重标记,失败重试不会重复发。
04复用了原型的哪些部分
上游 deepseek-ai/dsh-superagent-prototype @ 6337fc8,只读浅克隆,未改、未装依赖、未跑。
原型是什么
不是独立服务,而是 DSH(DeepSeek Harness)的一个插件包:入口靠 @deepseek-ai/cordis 插件框架注入,Router / Worker 都是 DSH Session,依赖 20 多个 0.2.1-alpha.2 的 peer 包,HTTP 面板走 dsh-host-webserver。部署要全局装 @deepseek-ai/dsh@0.2.1-rc.3、pnpm、tmux 和 DeepSeek 模型 key。
| 部件 | 原型位置 | 能直接跑? | 本项目做法 |
|---|---|---|---|
| GitHub 轮询 | src/sources/github.ts:198-311gh api 通知、since 游标回退 5 秒、ETag、X-Poll-Interval ≥60 秒、失败重试表 | 否cordis 插件 | Python 移植,照抄游标 / ETag / 间隔规则;用 gh CLI,无新依赖 |
| Fast Path | src/core/intake.ts:19-62policy.ts:167-211 | 否Engine / Repo 端口 | 移植进 sa.py 的 ingest:duplicate → 范围过滤(filtered)→ thread 扇出 → 无人认领才进 Router 队列 |
| Router 唤醒 | runtime.ts + notices.ts常驻 DSH 会话,2 秒批处理 | 否 | 自写:轮询进程攒批后 POST webhook 唤醒本席 |
| Worker 管理 | executor/dsh.ts、sessions.tsDSH Session + tmux | 否 | 自写:本席派后台子任务,各自目录,结束 sa report |
| Outbox / effect | app/outbox.ts、effects.ts、github-channel.ts持久队列、重试、FAILURES_BEFORE_ERROR、HTML 去重标记 | 否逻辑小,52 + 161 行 | 移植 effect 重试语义;发送前加「用户批准」闸门(原型无) |
| 看门狗 | 无独立 watchdog,只有 retry-health.ts 与 effects 失败计数 | — | 自写:routine 定时扫库 |
| 端口 | 原型自身不监听端口;面板由 DSH webserver 承载 | — | 不开端口,只出站 gh 与出站 webhook |
| Router 规则 | prompts/orchestrator / dispatch / admission | 参考 | 直接拿来当 Router 规则参考;docs/ 与上面源码当规格。原型的运行时代码一行不用 |
| 方面 | 原型 | Super-agent 复现 |
|---|---|---|
| 库 | orchestrator.sqlite,含 schema 迁移与 addColumns | state.sqlite,kv 只写 store.schema=1,无迁移 |
| 表 | kv / trigger / thread / ticket / outbox / effect / trigger_thread,另有 disposition、decision | 前 7 张同名、列结构照抄;第 1 阶段补了 disposition,decision 暂未建 |
| 订阅关系 | 相同放在 thread.value JSON 的 subscriptions(ticket id 数组) | |
| ticket inbox | notices / 扇出机制 | trigger id 列表存在 ticket JSON 里 |
| reply | receipt + effect 队列直接发 | 只写 outbox(pending,approved=false,带去重标记),不发送 |
| 卡片约定 | 相同report 截断 600 字,tickets 卡片 160 字预览 | |
| 准入 | admission 表 | 简化为 allow 白名单;context thread(关联而非本 ticket 的 thread)未区分 |
| Router 唤醒 | 常驻 DSH 会话收卡片 | webhook 唤醒本席;会话压缩改不了,所以上下文靠库 |
| 未移植 | PR 流 / review comments、open ticket 的 PR 与 CI 轮询、默认分支 CI、订阅 thread 逐个轮询(pollThreads)、分页(超过 50 条通知 / 100 条)、连续失败健康度上报 | |
05阶段计划
四个阶段,验收标准事先写死。前两个阶段的记录来自 stage0 / stage1 文档。
建库和 sa.py CLI(receive、route、read、report、reply)。
sa read trigger/<id> 读回原文沙盒仓 issue 里 @ 机器人账号,60–120 秒入库;20 条无关事件只唤醒本席 0 次,1 条 @ 唤醒 1 次。
issue 里提个小改动,出分支和 PR 草稿,issue 评论草稿进 outbox。
把故障和中断都演一遍。
kv / trigger / thread / ticket / outbox / effect 同名,列结构照抄原型(id/key + JSON value;effect 原样保留 seq / generation / attempts / error / due / since,第 0 阶段未使用)。另外保留原型的 trigger_thread(thread 与 trigger 的索引),共 7 张表。订阅关系同原型,放在 thread 的 subscriptions 里。
仓库改名为 superagent-demo,代码、测试和脚本里的旧名已全部替换;机器人身份核对为 dailiuyi-bot。真实只读跑过一轮,issues / comments 两个流都能读到。开跑前清了库(旧数据另行备份),游标从首次真实轮询时刻重新开始。常驻 poller 经用户批准启动,单轮异常不退出,日志带 UTC+8 时间戳,前三轮约 60 秒一轮、均为空。
06关键代码要点
sa.py 是 CLI 与 Fast Path,poller.py 负责轮询,tests/test_stage1.py 用注入的假 gh 跑离线测试。下面的伪代码按 stage1 记录整理,是示意,不是源码原文。
def ingest(t):
if exists(t.id):
return "duplicate" # 什么都不写
save_trigger(t) # 原文一律入 trigger 表
if t.author == bot_user:
return filtered("self") # 防自激
if t.author not in allow:
return filtered("not_allowed") # 留痕,但永不唤醒
subs = subscriptions(t.thread)
if subs:
# open 直接进 inbox;done 仅真人可重唤醒
# stopped 不唤醒;[bot] 后缀不算真人
deliver_to_inbox(subs, t)
return accepted(needs_router=False)
# bot_user 或 mention_names,整词、大小写不敏感
if mentioned(t.text):
kv["router.queue"].append(t.id)
return accepted(needs_router=True)
return filtered("not_mentioned")
间隔 = max(60, X-Poll-Interval, poll_min_seconds)。每轮先读 notifications?all=true&since=游标,只认配置里的仓库,再逐个读 issue 和评论;失败进 kv 的 github.retries,遇 410 或超过 24 小时放弃。
仓库流 issues / comments 升序读,since 取最新 updated_at 减 5 秒,上限 14 天并按小时取整;游标不动时带 ETag,304 不耗限额。只收首次轮询之后的新条目,跳过机器人自己的发言。游标都存在 kv。
同一条消息永远得到同一个 ID,重复交给 ingest 去重:
github:<repo>:issue:<n>:opened…:comment:<id>:<updated_at>
队首满 2 秒才发一次 POST,payload 是 triggers 列表加 sa read tickets --open 提示。成功清空队列并写 router.lastWake;没配 webhook 返回 no_webhook 并保留队列。密钥只从环境变量读进请求头,不打印、不落盘。
| 命令 | 作用 |
|---|---|
sa receive | 收一条消息并走 Fast Path:去重、过滤、直推订阅者或进 Router 队列 |
sa route | Router 建 ticket 并订阅 thread |
sa read | 读 trigger/<id>、ticket/<id>、disposition/<id>,或 tickets --open 卡片、triggers --pending(进过 Router 队列且未被认领) |
sa report | Worker 写回进展(截断 600 字);--done 完成,--stop 停止 |
sa reply | 把对外回复排进 outbox,"sent": false |
sa flush | 执行一次攒批唤醒(flush),队列为空或未配 webhook 时不发 |
$ python3 sa.py receive --json '{"id":"gh-1001", … "text":"@bot 请把 README 标题改成 Hello"}'
{ "status": "accepted", "delivered_to": [], "needs_router": true }
$ python3 sa.py receive --json '{"id":"gh-1001", … "text":"重复"}'
{ "status": "duplicate", "id": "gh-1001" }
$ python3 sa.py route gh-1001 --ticket tk-readme --thread sandbox#1 …
{ "status": "routed", "ticket": "tk-readme" }
$ python3 sa.py receive --json '{"id":"gh-1002", … "text":"顺便加一行说明"}'
{ "status": "accepted", "delivered_to": ["tk-readme"], "needs_router": false }
$ python3 sa.py reply tk-readme --target … 已改好,见分支 sa/readme-title
{ "status": "queued", "outbox": "ob-f90e0fb4", "sent": false }
$ python3 sa.py report tk-readme 完成 --done
{ "status": "reported", "ticket_status": "done" }
{
"repo": "dailiuyi/superagent-demo",
"allow": ["dailiuyi"],
"bot_user": "dailiuyi-bot",
"mention_names": ["dailiuyi-bot"],
"webhook_url": /* 已配置,不展示 */,
"batch_seconds": 2,
"poll_min_seconds": 60
}
离线测试覆盖 · 13 项
07安全边界
外部文本(issue、评论、群消息)一律当数据,不能改规矩。
配置里 allow 只有用户本人。不在名单里的作者,消息照样入库留痕,但去向记为 filtered,永远不会唤醒 Router 或进入 ticket。机器人自己的发言同样被过滤,避免自激。
推送、开 PR、部署、对外发消息一律要用户亲口批准;合并由用户自己做。reply 只是入队,状态是待批,不是已发。只有本席聊天里用户的原话算批准。
只碰沙盒仓和本项目自己的工作目录。Worker 在独立分支上提交,写手和审查用不同模型。公司仓和其他个人仓不在范围内,也不碰其他席位的交接目录。
常驻进程不监听任何端口,只出站。凭据只从环境变量和权限受限的文件读取,只传给对应的子进程,不打印、不写进日志;已核对日志里没有 token 字样。
08风险与下一步
仍待拍板
看门狗频率(每 15 分钟还是每小时,评估里倾向 15 分钟);GitLab 放到后面;QQ 群接入建议本期不接,避免和已有的群聊席位抢消息。跑通后要不要推广另议。