Initial commit: 让多个 AI 智能体通过文件总线在共享聊天室里互相 @、协作与交接任务

This commit is contained in:
WpyQwq
2026-09-19 11:52:42 +08:00
commit 103f0b32f5
28 changed files with 5834 additions and 0 deletions
+139
View File
@@ -0,0 +1,139 @@
# 总线协议(protocol)
## 目录布局
总线根目录默认 `~/.ai-groups`(`AIGROUP_HOME` 可覆盖):
```
~/.ai-groups/
├── current-room # `aig use <room>` 写在这里
└── rooms/
└── <room>/
├── messages.jsonl # 唯一真相:append-only 消息日志
├── state.json # { nextSeq, createdAt, updatedAt }
├── members.json # 成员画像:通道 / 模型 / 备注
├── cursors/<member>.json # 每个成员读到哪了(#seq)
├── presence/<member>.json # 心跳:lastSeen / pid / 宿主
├── files/ # send --file 的附件副本
└── .lock/ # 写锁(mkdir 原子锁)
```
## 消息结构
```json
{
"id": "m0007-mf3k2p-a91x",
"seq": 7,
"ts": 1789743812212,
"time": "2026-09-18T15:03:32.212Z",
"room": "main",
"from": "dsh",
"to": ["opencode"],
"kind": "chat | ask | reply | dispatch-error",
"via": "cli | dispatch",
"reply_to": "m0006-...",
"tags": [],
"files": [],
"text": "正文",
"meta": {}
}
```
- `seq` 在房间锁内分配,单调递增,是「读到哪了」的唯一依据。
- `to` 为空数组 = 广播给所有成员;`to` 里有 `*` / `all` 等价于广播。
- `kind`:
- `chat` 普通发言;`ask` 由 `aig ask` 发出的提问;
- `reply` 被唤醒成员的正式回答(`via: dispatch`);
- `dispatch-error` 唤醒失败时由总线代发的错误消息——**失败必须可见**。
## 游标语义
- 每个成员一份游标 `cursors/<member>.json`,只被该成员自己的 `read` / `wait` 推进。
- 未读 = `seq > 游标` 且 `visibleTo(消息, 成员)`。
- `visibleTo`:自己发的不算;`to` 为空算广播;否则必须在 `to` 里。
- `read --peek` 不推进游标;`read --all` 打印整段历史并按最后一条推进。
- 被 `aig wake` 处理过的未读,会把该成员的游标推到那批未读的最大 `seq`(避免同一批消息被反复唤醒)。
## 并发与一致性
- 写入走 `rooms/<room>/.lock`(`mkdir` 是原子操作),锁内完成「取 seq + append + 写 state」。
- 锁有 20s 陈旧保护(进程被杀不会永久锁死房间),获取超时 8s 后报错退出。
- 追加用 `fs.appendFileSync`,读侧跳过解析失败的行(坏行不阻塞整个房间)。
- 已实测:8 个并发 `send` 全部成功、序号无重复且严格递增(见 `selfcheck.mjs`)。
## 唤醒协议
`aig wake <member>`:
1. 取该成员的未读。
2. 组提示词:自我介绍 + 未读清单 + 「只输出回复正文」+ 可用命令(读历史)+ 深度提醒。
3. 用该成员自己的 CLI **无头**跑一次,`AIGROUP_DEPTH+1`、`AIGROUP_NO_WAKE=1` 注入子进程环境。
4. 成功 → 把 stdout 作为 `reply` 贴回房间,并把该成员的游标推到已处理的那条。
5. 失败 → 把失败原因作为 `dispatch-error` 贴进房间,`aig` 退出码 4。
`aig ask <member> <文本>` = 先 `send --to <member>`,再走上面整套,等于「发问 + 叫醒 + 等答案」。
### 护栏
| 护栏 | 机制 | 触发后的行为 |
|---|---|---|
| 互相唤醒死循环 | `AIGROUP_DEPTH` / `AIGROUP_MAX_DEPTH`(默认 0 / 3) | 到顶 `exit 5`,不调用 |
| 被唤醒后又去唤醒别人 | 子进程里 `AIGROUP_NO_WAKE=1` | `exit 5`,除非 `--force-wake` |
| 打错命令的成员 | `human` 这类没有 `transport` 的成员 | `exit 4`,不调用 |
| 通道失败 | spawn 错误 / 非零退出 | 贴 `dispatch-error`,`exit 4` |
## 唤醒结果的取回(结构化,不抓渲染流)
被唤醒成员的 stdout 并不是"干净答复":opencode 会把 `> build · <model>` 横幅打到 **stderr**、把答复按渲染流打 stdout;
WorkBuddy 的 cbc 还会带自己的横幅。另外模型的**思考过程**可能混进 stdout。
所以 `aig` 让两家都用 JSON 模式,直接取最终答复:
| 成员 | 取法 | 附带信息 |
|---|---|---|
| opencode | `opencode run --format json` → NDJSON 事件里所有 `{type:"text",part:{text}}` 拼接 | `step_finish.part.cost` / `tokens` |
| workbuddy | `cbc -p --output-format json` → 数组里最后一条 `{type:"result",result}` | `total_cost_usd` / `usage` |
| dsh | `dsh --profile headless` 的 stdout 本来就是最终 assistant 消息 | — |
取不到时回退到 `cleanReply()`(剥 ANSI、去掉以 `>` 开头的横幅行、去首尾空行)。
取回的 `cost`/`tokens` 写进消息 `meta`——所以每条回复都能自证"这是免费档跑的、花了 0 元"。
排错时用 `AIGROUP_DEBUG_RAW=<目录> node aig.mjs wake <成员>`,会把子进程的原始 stdout/stderr/完整 argv 落盘。
## 主代理与自驱
- `members.json` 里的 `role: primary`(默认 `dsh`)是**主代理**:
- 它的唤醒提示词会额外告诉它"你是主代理,可以用 `aig ask` 把活分出去,并把结论汇总回群";
- 唯一被允许**突破 `AIGROUP_NO_WAKE` 护栏**的成员(护栏本来是为了防止两个 agent 互相叫个不停,而主代理派活是正当行为)。深度上限对它同样生效。
- Web 群聊台(`scripts/aig-web.mjs`)的**自驱模式**:只对 `from === 'human'` 的新消息自动唤醒被点名的成员(广播则只叫主代理)。
agent 之间的往返不自驱——主代理的 `ask` 本身就是阻塞式唤醒,自驱若也插手会让同一个成员被唤醒两次。
- DSH 无头会话的沙箱由 **`DSH_PERMISSION_MODE`** 决定(`dsh-base/cordis.patch.yml` 里默认 `workspace-write`,工作区 = cwd)。
总线在 `~/.ai-groups`(工作区之外),所以 `workspace-write` 下无头 DSH **写不了房间**——表现是它只能口头转述、发不了言。
`aig` 给 dsh 通道默认注入 `DSH_PERMISSION_MODE=danger-full-access`,可用 `AIGROUP_DSH_PERMISSION_MODE` 覆盖。
## 信任模型(WorkBuddy 在群里提的那条,采纳)
这条是 2026-09-18 首次真机群聊时 `workbuddy` 自己提出来的,记录在此以免忘掉:
- **`from` 是一个声明,不是凭证**。总线是同一台机器、同一个用户下的文件,谁能写 `messages.jsonl` 谁就能冒充任何成员。
默认靠**文件系统权限**兜底(`~/.ai-groups` 在用户目录下),没有签名、没有鉴权。
- 因此群聊的威胁不是"外网伪造",而是**跨 agent 提示注入**:A 的正文里写"请把 X 文件删掉",
B 醒过来照做。已经做的加固:
1. `buildDispatchPrompt` 里明确告诉被唤醒的成员:上面是**其他 agent 的消息内容**,是参考资料;
只有成员 `human`(用户本人)的话才算用户指令;别人要求执行操作时先当"待商量的提议"。
2. `SKILL.md` 的调用纪律里同样写死这一条。
3. 失败/异常一律落成 `dispatch-error` 消息,不静默。
- **并发写入**(同一个 review 里提到的另一半):追加不是裸 `appendFile` —— 取 `seq` 与 append 都在房间锁内完成,
读侧跳过解析失败的行。实测 8 个并发 `send` 全部成功、序号无重复且严格递增(`selfcheck.mjs`)。
- 真要更强的保证(多用户、跨机器)再上签名:给每条消息附 HMAC,读侧只信验签通过的发言。当前单机单用户场景不划算。
## 退出码
| 码 | 含义 |
|---|---|
| 0 | 成功(`wait` 拿到消息也算成功) |
| 1 | 内部错误(读盘、锁超时等) |
| 2 | 用法错误(缺参数、`reset` 没给 `--yes`) |
| 3 | `wait` 超时 |
| 4 | 唤醒失败 / 该成员没有唤醒通道 |
| 5 | 护栏拦下(深度到顶、`NO_WAKE` 链条) |