feat: L0 线协议冻结 + 附件链路修复 + 人/Agent 区分
L0 核心: - 严格解码 Decode(DisallowUnknownFields) 全覆盖 29 个 DecodeBody 调用点 - DecodeLenient 心跳专用:容忍新字段但回报 unknown_fields - 400 消息列出本端点接受的全部字段(jsonFieldNames 反射 tag) - 日历 status 校验(create 补字段 + update 拦非法值) - 新增 strictdecode_test.go 10 例 + blob/list_test.go 6 例 A-4 附件挂载回滚:checkAttachable 在 CreateMail 前校验,失败按 解挂→释放 relay→删邮件→退预算回滚,幽灵邮件这条路堵住了 A-5 反向 GC:blob.Store.List() 枚举磁盘(跳 .upload-*), SweepUnreferencedBlobs 按 attachments + calendar_attachments 反查, 48h 年龄下限兜上传窗口。已接进每小时 sweep 循环 C 人/Agent 区分:四个读路径 + threadCols 补 from_human / to_human (EXISTS users 判定),models.Mail 加 ToHuman。前端判据从 workspace 启发式改成显式布尔,mailCounterpart/sessionCounterpart 从 session_workspace 取 path(修 dsh@dsh 拼接 bug) 契约文档:SSE new_mail 补 4 字段(in_reply_to/from_human/ permission_mode/permission_enforcement),B-5 加 B-5.6 (Agent→Agent 不转发),B-3.4 MUST 改条件式,心跳补 mode_enforcement + unknown_fields,demo 死链修复 + from_human 检查 验收清单加 Agent→Agent 负向对照项
This commit is contained in:
@ -193,6 +193,7 @@ name@path.session
|
||||
| B-2.3 | 带上模型目录(拉不到则**省略字段**,不传空数组) | SHOULD |
|
||||
| B-2.4 | 带上平台会话快照(同上) | SHOULD |
|
||||
| B-2.5 | 心跳失败不影响 SSE 与投递 | MUST |
|
||||
| B-2.6 | 请求体带 `mode_enforcement`(`native` / `advisory`),告知平台本侧的权限强制力 | MUST |
|
||||
|
||||
> **B-2.1 为什么失败不报错**:网络抖动很常见,而 Gateway 已经有可见的失败信号
|
||||
> —— 持续连不上时 `last_seen` 会让它显示为离线。插件自己再打一串错误日志只会
|
||||
@ -200,6 +201,14 @@ name@path.session
|
||||
>
|
||||
> **B-2.2 为什么范围随心跳回传而不是插件轮询**:管理员在配置页改了范围后最多
|
||||
> 一个周期(30 秒)生效,不需要重启插件;也不需要插件多起一个请求。
|
||||
>
|
||||
> **B-2.6 为什么上报 mode_enforcement**:平台表达的档位(`plan` / `workspace` / `full`)
|
||||
> 是「我要求你做到什么」,而平台能实际做到的(沙箱、审批、仅通知)取决于本侧的
|
||||
> 强制力。两者分开记录,人在界面才能看到「这个平台无法强制这一档」。
|
||||
>
|
||||
> **响应里的 `unknown_fields`**:心跳是唯一走宽容解码的端点(`DecodeLenient`),
|
||||
> 容忍插件带了平台不认识的字段。但容忍不等于咽下去 —— 响应里会回 `unknown_fields`
|
||||
> 数组,插件应据此判断自己是否比平台新得太多,必要时降级。
|
||||
|
||||
### B-3 收到 `new_mail`(MUST)
|
||||
|
||||
@ -222,7 +231,7 @@ new_mail 到达
|
||||
| B-3.1 | cwd **必须**取自 `to_workspace`,不得自己拼临时目录 | MUST |
|
||||
| B-3.2 | 新建会话时**不传**占位标题(会掐掉平台自己的命名机制) | MUST |
|
||||
| B-3.3 | 维护 `mail session_id ↔ 平台 session id` 双向映射 | MUST |
|
||||
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」 | MUST |
|
||||
| B-3.4 | 提示词里写明「回信由插件自动发,不必调 send_mail」**(仅 `from_human === true` 时)** | SHOULD |
|
||||
| B-3.5 | 提示词里带 `mail_id`,让模型能自己查这封 | SHOULD |
|
||||
| B-3.6 | 投递失败要让人看到(日志 + 见 `B-6`) | MUST |
|
||||
| B-3.7 | `platform_session_id` 非空时**必须**投进那条平台会话,不得新建 | MUST |
|
||||
@ -235,6 +244,10 @@ new_mail 到达
|
||||
> **B-3.4 为什么要在提示词里说**:不说的话模型会自己调 `send_mail` 回信,
|
||||
> 而插件在轮次结束时也会自动转发一次 —— 同一件事两封邮件。生产里真实发生过。
|
||||
> 说了之后仍要保留 `B-5.3` 的去重兜底:提示词是建议,去重是保证。
|
||||
>
|
||||
> **为什么改为 SHOULD**:`from_human === false` 时自动转发规则 `B-5.6` 已经拦住,
|
||||
> 不需要也不应该在提示词里说「回信由插件自动发」—— 那对 Agent 收件方是假话。
|
||||
> 提示词是建议,去重是保证,这条不变。
|
||||
|
||||
#### B-3.7 接管平台会话(MUST)
|
||||
|
||||
@ -301,12 +314,19 @@ pi 的会话是磁盘上的 `.jsonl`,**没有任何锁机制**(SDK 里 `floc
|
||||
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| B-5.6 | **收件方是 Agent**(`from_human === false`)→ **不转发** | MUST |
|
||||
| B-5.1 | 只取最后一条 assistant 消息里 **`type === 'text'`** 的块 | MUST |
|
||||
| B-5.2 | 带 `relay: "summary"` + 平台侧稳定 id 作 `relay_key` | MUST |
|
||||
| B-5.3 | 本轮模型已亲手回过这条线索 → **不转发** | MUST |
|
||||
| B-5.4 | 文本为空 → 不发空邮件 | MUST |
|
||||
| B-5.5 | 只对**邮件驱动**的会话转发(人在平台 UI 里开的会话不转) | MUST |
|
||||
|
||||
> **B-5.6 为什么排在最前面**:Agent → Agent 的邮件如果自动转发,两边插件都认为
|
||||
> 「我只要把话说完就行」,实际上彼此持续唤醒 —— 生产实测 pi 与 dsh 互相客套 6 轮
|
||||
> 直到撞上 hop 上限。此规则的判据是 `from_human`:它由服务端用
|
||||
> `EXISTS (SELECT 1 FROM users WHERE username = from_name)` 判定,
|
||||
> 不依赖插件自己的猜测。
|
||||
|
||||
> **B-5.1 丢掉 reasoning**:思考过程不该出现在邮件里 —— 它对收件人没有意义,
|
||||
> 而且经常包含「我先假设…」这类会被误读为结论的内容。
|
||||
>
|
||||
@ -434,13 +454,16 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| B-8.1 | `relay_key` 用平台的权限 id;平台不给 id 时用 `会话:工具:callId` 拼一个 | MUST |
|
||||
| B-8.2 | 转发失败 → 让位给平台本地 UI,不要占着钩子 | MUST |
|
||||
| B-8.2 | 转发**暂时**失败(5xx / 408 / 429 / 网络)→ 让位给平台本地 UI | MUST |
|
||||
| B-8.2b | 转发**永久**失败(4xx,除 408 / 429)→ 当场拒绝并把原因告诉模型 | MUST |
|
||||
| B-8.2c | 拒绝给模型的文本必须是**真实原因**,不能是平台的「用户拒绝了」写死文案 | MUST |
|
||||
| B-8.3 | 同一次询问重复触发只产生一封邮件(服务端按 `relay_key` 幂等) | MUST |
|
||||
| B-8.4 | 邮件正文带足够上下文(工具名、参数摘要、**触发这次询问的任务与派活人**),让人能判断 | SHOULD |
|
||||
| B-8.5 | 权限询问**不消耗配额** | MUST |
|
||||
| B-8.6 | **不传 `to`** —— 决策人由服务端解析 | MUST |
|
||||
| B-8.7 | 平台支持「永久允许」时,选项里必须给出「一直同意」并**真的记住它** | MUST |
|
||||
| B-8.8 | 免批的作用域是 **(会话, 工具名)**;决策文本判定用 `lib/permission-grants.js` | MUST |
|
||||
| B-8.9 | `relay_key` 发出前必须用 `lib/relay-key.js` 的 `clampRelayKey` 收敛长度 | MUST |
|
||||
|
||||
> **B-8.1 为什么必须是平台的 id**:服务端会随决策事件把 `relay_key` 回传,
|
||||
> 插件重启丢了内存映射也能对上(`B-4.2`)。自己生成的随机 id 重启后就对不上了。
|
||||
@ -459,9 +482,74 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
> 409** 解析,那是唯一能看到整条线索的地方。插件只有本地那点上下文,
|
||||
> 猜不出「这条 Agent 链最初是谁派的活」。
|
||||
>
|
||||
> 收到 409(整条链上没有人类)时按 `B-8.2` 处理 —— 服务端已经判定没人可问,
|
||||
> 收到 409(整条链上没有人类)时按 `B-8.2b` 处理 —— 服务端已经判定没人可问,
|
||||
> 继续等下去就是死锁。
|
||||
>
|
||||
> **B-8.2 / B-8.2b 为什么必须分开(生产事故)**:原本只有「除 409 一律让位」一条。
|
||||
> 实测碰到 pi 侧 `relay_key 过长(上限 160 字节)` 返回 **400**,被归入「暂时失败」
|
||||
> 让位给本地决策 —— 而邮件驱动的会话根本没有本地 UI,**那条 bash 就在无人
|
||||
> 批准的情况下执行了**。同一条会话 22 秒后另一次 key 正常则成功发出询问 ——
|
||||
> 所以守卫是**随机**失效的,比稳定失效更难发现。
|
||||
>
|
||||
> 判据(`lib/relay-key.js` 的 `isPermanentFailure`,homeagent 侧是 `relay_key.go`):
|
||||
>
|
||||
> | 状态 | 类别 | 理由 |
|
||||
> |---|---|---|
|
||||
> | 4xx(除 408 / 429) | 永久 | 请求本身有问题,重试一万次还是同一个结果 |
|
||||
> | 408 / 429 | 暂时 | 超时与限流,等一会儿真的可能成功 |
|
||||
> | 5xx | 暂时 | 服务端的问题 |
|
||||
> | 无状态码 | 暂时 | 网络层(DNS、连接被拒) |
|
||||
>
|
||||
> 401 归到**永久**:密钥无效要人去后台重新登记,不是等一等就好的事。
|
||||
> (实测过一次:opencode 被停用后拿着已撤销的密钥重试了 18 小时,2690 次 401。)
|
||||
>
|
||||
> **永久失败必须 fail closed**:宁可让模型看到「权限系统坏了」并自己改道,
|
||||
> 也不能悄悄放行一条没人看过的命令。拒绝时把原因写进 `reason`(平台会当工具
|
||||
> 报错回给模型),它才知道下一步该换什么做法。
|
||||
>
|
||||
> **B-8.2c 为什么单列一条(DSH 实例)**:“当场拒绝”在有些平台上不等于
|
||||
> “模型知道为什么被拒”。DSH 把 `approval/request` 的返回值翻译成模型可见
|
||||
> 文本时用的是 `@deepseek-ai/dsh-tools` 里写死的句子:
|
||||
>
|
||||
> ```js
|
||||
> case "rejected": reason = `the user rejected tool "${exec.name}"`
|
||||
> case "unavailable": reason = `... no approval channel is available`
|
||||
> ```
|
||||
>
|
||||
> 于是插件因为「这条链上没有人类」主动拒绝时,模型看到的是
|
||||
> 「the user rejected tool bash」—— **没有任何用户拒绝过它**。模型会以为人
|
||||
> 不同意,而不会去换一条路;服务端给的 `suggestion` 只进了日志。
|
||||
>
|
||||
> 三个平台的出口不同:
|
||||
>
|
||||
> | 平台 | reason 能不能直达模型 | 做法 |
|
||||
> |---|---|---|
|
||||
> | pi | 能 | `return { block: true, reason }` |
|
||||
> | opencode | 能 | `output.status = "deny"` + `output.reason` |
|
||||
> | DSH | **不能** | 在 `approval/request` 里记下 `(agentId, callId) → 原因`,再在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉那句写死的文案 |
|
||||
>
|
||||
> DSH 那条路可行的依据:门禁拒绝的调用**也会**进 post-execute
|
||||
> (`pre-execute` 的 deny 走 `{kind:"post-result"}` → `finalizeScheduledExecution`
|
||||
> → `postExecute`)。替换必须是**一次性**的(同一 callId 只换一次)、
|
||||
> 按 `(会话, callId)` 隔离、只对 `isError` 的结果生效,否则会把一个原因
|
||||
> 贴到别的失败上。
|
||||
>
|
||||
> **B-8.9 为什么不能直接截断**:toolCallId 的长度不在插件控制下。启用
|
||||
> extended thinking 时 Bedrock 把**思考签名**拼进了 toolCallId,实测同一条会话里
|
||||
> 两种形态混着出现:
|
||||
>
|
||||
> ```
|
||||
> toolu_bdrk_01F6roEBHa8nic1mYiyLgNWK 35 字节
|
||||
> toolu_bdrk_01FsWUWhEs4arnEWo44gqzLC~sig1:CAISoQIK… 437 ~ 13601 字节
|
||||
> ```
|
||||
>
|
||||
> 直接截断会让前缀相同的两次调用**撞成同一个键** —— 而这个键的全部意义
|
||||
> 是幂等,撞键意味着第二次询问被服务端当重复请求丢掉。`clampRelayKey`
|
||||
> 保留可读前缀(日志里还能 grep 会话 id)+ `:sha256:<原始键的完整哈希>`,
|
||||
> 且**未超限时原样返回** —— 否则插件升级前后会算出不同的键,等于把已发出的
|
||||
> 询问变成新询问。Node 与 Go 两侧必须对同一输入算出同一输出(已用跨语言
|
||||
> 比对验证)。
|
||||
>
|
||||
> **B-8.4 为什么要带派活人**:决策人未必是这条会话的参与者。Agent 转派出来的
|
||||
> 会话,人从没见过它,只给一句「是否允许执行 bash」无从判断 —— 得知道这活是
|
||||
> 谁派的、为的什么事。
|
||||
@ -642,11 +730,17 @@ SSE 只推连上之后的事件。插件重启前发来的邮件不会再推一
|
||||
| # | 要求 | 强度 |
|
||||
|---|---|---|
|
||||
| D-2.1 | 钩子内**不得**阻塞等待(会挂死整个平台请求) | MUST |
|
||||
| D-2.2 | 转发失败时让位给本地 UI(`return next()` 或保持「询问中」) | MUST |
|
||||
| D-2.2 | 转发**暂时**失败时让位给本地 UI(`return next()` 或保持「询问中」) | MUST |
|
||||
| D-2.2b | 转发**永久**失败(4xx)时当场拒绝,不让位 | MUST |
|
||||
| D-2.3 | 记住 `平台权限 id → 挂起项` 的映射 | MUST |
|
||||
|
||||
> **D-2.2 为什么必须让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
|
||||
> 不会来的回答。让位之后本地 UI 还能接管。
|
||||
> **D-2.2 为什么要让位**:转不出去还占着那个钩子,平台会挂在那儿等一个永远
|
||||
> 不会来的回答。让位之后本地 UI 还能接管 —— **但这个前提只对人坐在 TUI 前面
|
||||
> 的会话成立**。邮件驱动的会话没有人在看,让位等于无人把关。
|
||||
>
|
||||
> 因此「让位」只能给**暂时**失败:网络抖动、Gateway 正在重启 —— 那些情形下
|
||||
> 插件不知道下一秒会不会好,而人确实可能在本地看到弹窗。永久失败(4xx)
|
||||
> 已经知道结果了,让位就是静默放行(见 `B-8.2b`)。
|
||||
|
||||
### D-3 无法指定单轮模型(缺 `C-13`)
|
||||
|
||||
@ -828,7 +922,8 @@ POST /api/v1/agent/heartbeat
|
||||
],
|
||||
"models": [
|
||||
{ "provider": "llmsproxy", "model": "AUTO", "display_name": "AUTO (smart routing)" }
|
||||
]
|
||||
],
|
||||
"mode_enforcement": "native"
|
||||
}
|
||||
```
|
||||
|
||||
@ -842,7 +937,8 @@ POST /api/v1/agent/heartbeat
|
||||
"platform_sessions_synced": 12,
|
||||
"models_synced": 9,
|
||||
"allowed_models": [{ "provider": "llmsproxy", "model": "AUTO" }],
|
||||
"models_unrestricted": false
|
||||
"models_unrestricted": false,
|
||||
"unknown_fields": []
|
||||
}
|
||||
```
|
||||
|
||||
@ -881,7 +977,11 @@ Agent 侧只会收到两个事件:
|
||||
"session_alias": "refactor-imports",
|
||||
"reply_address": "admin@.refactor-imports",
|
||||
"self_address": "pi@/home/program/agentmail.refactor-imports",
|
||||
"platform_session_id": ""
|
||||
"platform_session_id": "",
|
||||
"in_reply_to": "",
|
||||
"from_human": true,
|
||||
"permission_mode": "workspace",
|
||||
"permission_enforcement": "native"
|
||||
}
|
||||
```
|
||||
|
||||
@ -894,6 +994,10 @@ Agent 侧只会收到两个事件:
|
||||
| `reply_address` | 「把回信发回这条会话」的现成地址 |
|
||||
| `self_address` | 对方应当用来称呼自己的地址,供转发/报告时引用 |
|
||||
| `platform_session_id` | 非空 = 投进**这条已存在的平台会话**(见 `B-3.7`);空 = 照旧 |
|
||||
| `in_reply_to` | 父邮件 id:这封信是回复哪封的;空串 = 线索根 |
|
||||
| `from_human` | `true` = 发件方是人类(服务端用 `EXISTS users` 判定)。**`B-5.6`** 据此决定是否自动转发 |
|
||||
| `permission_mode` | 所属会话的权限档位(`plan` / `workspace` / `full`)—— 插件应据此设置沙箱/审批策略 |
|
||||
| `permission_enforcement` | 平台对该档位的实际强制力(`native` / `advisory`)—— 插件据此决定是**强制执行**还是**打日志告警** |
|
||||
|
||||
> **`reply_address` 应当放进提示词。** 插件会自动转发本轮总结(`B-5`),
|
||||
> 但模型仍然会主动发信 —— 要抄送第三方、或分多封交代不同的事时。让它自己拼三维地址
|
||||
@ -1059,6 +1163,71 @@ GET /api/v1/attachments/{id}
|
||||
| `discovery.js` | 寻址发现工具的渲染(`T-8`~`T-12`) |
|
||||
| `rename-proposal.js` | 会话改名标记的构造与回执文案(`T-13`) |
|
||||
| `permission-grants.js` | 权限决策文本判定 + 免批授权表(`B-8.7` / `B-8.8`) |
|
||||
| `relay-policy.js` | 自动转发适用范围 + 据此给模型说什么话(Agent 间不转) |
|
||||
| `relay-key.js` | `relay_key` 长度收敛 + 永久/暂时失败分类(`B-8.2b` / `B-8.9`) |
|
||||
| `adopt.js` | 接管平台会话的 id 提取与缺失报文(`B-3.7`) |
|
||||
| `permission-mode.js` | 权限档位翻译(AgentMail 声明什么 → 平台怎么下发) |
|
||||
| `bounded.js` | 有界 Map/Set:给常驻进程里「只增不减」的映射表兜上界 |
|
||||
|
||||
> **Go 子进程插件的例外**:homeagent 是 Go,import 不了 Node 模块。
|
||||
> 那几个模块在它那边是 `relay_policy.go` / `relay_key.go` / `bounded.go`,
|
||||
> 注释与判据原样搬过去,测试逐条对齐(`relay_policy_test.go` / `relay_key_test.go` /
|
||||
> `bounded_test.go`)。`clampRelayKey` 还额外要求**两边对同一输入算出同一输出**
|
||||
> (幂等键分叉就失去意义);`bounded.go` 的上限常量必须与 Node 侧同值
|
||||
> (一侧偷偷调小会让「重复投递」只在那个平台出现),但**淘汰策略允许不同** ——
|
||||
> Go 的 map 不保证遍历顺序,那边是 FIFO 而不是 LRU,理由写在文件顶部。
|
||||
|
||||
### 常驻进程里的表必须有出口
|
||||
|
||||
四个桥都是常驻进程(pi 的守护进程能跑几十天,另三个跟着平台一起活)。里面每一张
|
||||
「这条会话/这封邮件我处理过吗」的表,键都来自外部事件流 —— 会话数与邮件数随时间
|
||||
单调增长。**每张这样的表都必须有出口**,两条:
|
||||
|
||||
| 出口 | 时机 | 性质 |
|
||||
|---|---|---|
|
||||
| `session_archived` 事件 | 会话归档 | 确定性:归档后别名 404、不会再有邮件投进来,映射再无用处 |
|
||||
| 上限淘汰(`bounded.js`) | 超过上限 | 兜底:兜的是「一直不归档」 |
|
||||
|
||||
> **确定性的出口优先**:能确切知道该删的时候不该靠上限去猜。
|
||||
> `session_archived` 是 SSE 事件里唯一一个「这条会话到此为止」的信号,
|
||||
> 四个桥原来全都没处理它。
|
||||
|
||||
**不要给「还在等结果的东西」套上界**:待决权限询问(opencode 的
|
||||
`pendingPermissions`、DSH 的 `pendingApprovals`)里存的是 `resolve` 回调,
|
||||
静默淘汰一条会让对应的 `await` 永远不返回 —— 平台侧那次工具调用直接挂死。
|
||||
那些表有确定的清理路径(决策到达 / 超时 / 拆插件时 fail closed),不需要上界。
|
||||
上界只适合「记录已经发生过的事实」的表。
|
||||
|
||||
> **这类表不是内存暴涨的原因**:单条成本只有几十到几百字节。症状是跑够久之后
|
||||
> 进程里躺着几十万个再也不会被查到的条目,且 GC 回收不了(还被强引用着)——
|
||||
> 不会在开发和测试里出现,只在生产上跑了几周后表现为「重启一下就好了」。
|
||||
|
||||
### pi 专属:不要在心跳路径上调 `SessionManager.listAll()`
|
||||
|
||||
心跳每 30 秒要上报平台会话快照,而 `snapshotPiSessions` 只用四个字段
|
||||
(`id` / `cwd` / `name` / `modified`)。`listAll()` 为了拿这四个字段会把
|
||||
`~/.pi/agent/sessions` 下**每个 `.jsonl` 的每一行**读进来并 `JSON.parse`,
|
||||
还把所有消息正文拼成一个 `allMessagesText` 大字符串。
|
||||
|
||||
本机实测(115 个文件 / 145MB,其中单个会话 29MB、单行最长 2.63MB):
|
||||
|
||||
| 做法 | 耗时 | RSS |
|
||||
|---|---|---|
|
||||
| `listAll()` | 1431ms | 41 → 323MB(heapUsed 141MB) |
|
||||
| 只读 header 首行 | 3ms | 41 → 46MB |
|
||||
| `src/session-scan.mjs`(冷启动) | 516ms | 41 → 131MB |
|
||||
| `src/session-scan.mjs`(稳态) | 3ms | 重扫 0 字节 |
|
||||
|
||||
那 282MB 每 30 秒分配一次、随即变成垃圾。GC 收得掉(所以 RSS 呈锯齿而不是单调
|
||||
上升),但代价是常驻内存被垃圾撑到 300MB 上下,且每拍有 1.4 秒的**同步解析跑在
|
||||
事件循环上** —— 那期间 SSE 读循环停着,新邮件事件在 TCP 缓冲区排队。
|
||||
|
||||
`src/session-scan.mjs` 的三条省法:`id`/`cwd` 只在首行 header(读 4KB 就够);
|
||||
`name` 来自 `session_info` 行而那种行只有几百字节(按行扫描时长度超上限的行直接
|
||||
跳过、不 materialize);文件是 append-only 的,缓存 `size` 之后每拍只扫新增的尾巴。
|
||||
|
||||
> **它必须建一次并复用**:省内存全靠跨拍存活的 size 缓存。每拍新建一个等于每拍
|
||||
> 都冷启动,退回全量读的开销。
|
||||
|
||||
> **为什么必须逐字节相同而不是「行为一致」**:一侧改了另一侧没改,两个平台的行为
|
||||
> 会悄悄分叉 —— 同一封邮件在 A 平台标了已读、在 B 平台没标,而两处代码看起来都
|
||||
@ -1130,7 +1299,7 @@ GET /api/v1/attachments/{id}
|
||||
[ ] sqlite3 <db> "SELECT status, last_seen FROM agents WHERE agent_name='<name>'"
|
||||
→ status=online,last_seen 每 30 秒推进(B-2.1)
|
||||
[ ] 断网 60 秒再恢复:SSE 自动重连,期间的邮件通过 Last-Event-ID 补回(D-7.2)
|
||||
[ ] kill 插件:未决权限询问全部 fail closed(B-8.2;平台无审批环节时跳过)
|
||||
[ ] kill 插件:未决权限询问全部 fail closed(B-9.2;平台无审批环节时跳过)
|
||||
```
|
||||
|
||||
### 7.3 主链路
|
||||
@ -1170,6 +1339,14 @@ GET /api/v1/attachments/{id}
|
||||
[ ] 模型主动调 send_mail 回信的那一轮
|
||||
→ 只有一封邮件,没有额外的自动转发(B-5.3)
|
||||
|
||||
[ ] Agent → Agent 负向对照(B-5.6):
|
||||
→ 向另一个 Agent 发一封(from_human === false)
|
||||
→ 收件方 Agent 的日志里**没有**「自动转发」相关条目
|
||||
→ 收件方 Agent 的 sessions.used_rounds 不因自动转发而涨
|
||||
→ 如果收件方 Agent 的模型跑了但没调 send_mail → 邮件链到此为止,发件方收不到任何回信
|
||||
→ 如果收件方 Agent 的模型调了 send_mail 回信 → 那封回信的 from_human === false
|
||||
→ 收件方的收件方也不自动转发
|
||||
|
||||
[ ] 模型带 propose_alias 发信(T-13)
|
||||
→ 入库正文里**没有** agentmail:rename-session 标记(已被剥掉)
|
||||
→ mails.rename_alias / rename_reason 记下了提议
|
||||
@ -1232,8 +1409,30 @@ GET /api/v1/attachments/{id}
|
||||
→ 权限邮件的 to_name 是**人**(会话 owner 或线索里最近的人类),不是 Agent
|
||||
sqlite3 "SELECT to_name FROM mails WHERE mail_type='permission_request' …"
|
||||
→ 正文里带得出「触发任务」与「任务来自」(B-8.4)
|
||||
→ 整条链上确实没有人类时:服务端返回 409,插件让位给本地 UI,
|
||||
**不是**无声挂起(日志里要能看到让位那一行)
|
||||
→ 整条链上确实没有人类时:服务端返回 409,插件**当场拒绝**并把原因告诉模型,
|
||||
**不是**无声挂起(日志里要能看到拒绝那一行)
|
||||
|
||||
[ ] **永久失败不得静默放行**(B-8.2b)
|
||||
造:把转发请求里的 relay_key 换成 200 字节的串(超服务端 160 上限),
|
||||
或把密钥改错造 401
|
||||
→ 服务端返回 4xx
|
||||
→ 插件**当场 block / deny / rejected**,日志里有「永久失败」字样
|
||||
→ 那次工具调用**没有执行**(这是生产事故的反面:
|
||||
原本 400 被当暂时失败让位,bash 就在无人批准下跑了)
|
||||
→ 改造 503(停接 Gateway):插件才该让位给本地 UI
|
||||
|
||||
[ ] **模型看到的拒绝理由是真实原因**(B-8.2c)
|
||||
造:让 Agent 把活派给自己另一条会话并要求跑 bash(整条链上无人类)
|
||||
→ 模型收到的工具报错里带得出「没有人类用户」与服务端的 suggestion
|
||||
→ **不得**是平台写死的「用户拒绝了」/ 「the user rejected tool X」
|
||||
(那句话是假的 —— 没有任何用户看过这次询问)
|
||||
→ 模型随后改道或在回信里说明需要人工执行,而不是反复重试同一个工具
|
||||
|
||||
[ ] **relay_key 收敛后能通过**(B-8.9)
|
||||
造:clampRelayKey("<36 字节会话 id>:" + "A".repeat(500))
|
||||
→ 结果≤ 160 字节、保留会话 id 前缀、尾部是 :sha256:<64 位 hex>
|
||||
→ 直接 POST /permission/request 得 200(未收敛的原始键得 400)
|
||||
→ Node 与 Go 两侧对同一输入算出**完全相同**的键
|
||||
|
||||
[ ] **「一直同意」真的免批**(B-8.7 / B-8.8)
|
||||
造:一封信里要求连续三次单独调用 bash
|
||||
@ -1286,6 +1485,7 @@ GET /api/v1/attachments/{id}
|
||||
| 轮次结束 | `session.idle` 事件 | `agent/status` → `idle` | `prompt()` 的 promise resolve;事件是 `agent_end` | 无「轮次」事件;靠 `RegisterOutputChannel` 的 handler 被调用 |
|
||||
| 模型失败信号 | `session.error` 事件 | `turn/end` 的 `reason.kind === 'error'` | `prompt()` reject **或** 末条 assistant 的 `stopReason==='error'` | 无(核心不把模型错误暴露给插件) |
|
||||
| 权限钩子 | `permission.ask`(**同步,不能等**) | `approval/request`(异步 waterfall,**能等**) | `tool_call` 扩展事件(**能 await**,实测) | **无审批环节**(核心不问人)。有 `StageBeforeToolcall` 可否决,但语义不同 —— 见 `B-8` |
|
||||
| 拒绝理由能不能递给模型 | 能:`output.reason` | **不能** —— `'rejected'` 被翻译成写死的 `the user rejected tool "X"`;需在 `tools/post-execute` 返回 `{kind:'block', feedback}` 换掉(`B-8.2c`) | 能:`{block:true, reason}` | N/A |
|
||||
| 会话列表 | `client.session.list()` | `ctx.sessionQuery.listSessions()` | `SessionManager.listAll()`(**不传参**,传字符串会被当自定义目录) | N/A |
|
||||
| 模型目录 | `client.config.providers()`(`models` 是**对象**) | `ctx.llm.listProviders()` + `listModels()` | `modelRuntime.getAvailable()`(**不是** `getModels()`:1221 条里只有 1 条能用) | N/A(模型由核心配置,插件不选) |
|
||||
| 别名来源 | `session.slug`(创建时就有) | 模型标题派生 | **邮件主题派生**(SDK 会话没有平台标题,见下) | 邮件主题派生(无平台标题) |
|
||||
|
||||
Reference in New Issue
Block a user