140 lines
7.6 KiB
Markdown
140 lines
7.6 KiB
Markdown
# 总线协议(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` 链条) |
|