# 模型与权限切换机制 本文档记录子代理模型/权限切换的实现依据与内部机制。README 只讲怎么用,机制细节在这里。 ## 一、子代理会话为什么没有模型/权限切换按钮 主会话输入框右上角有模型选择器,子代理会话没有。这是宿主在 UI 层直接关掉的: ```js // client/ui-model-selection/src/client/index.ts:134 available: session => sessions.subagentAddress(session.sessionId) === undefined, // client/ui-model-selection/src/client/directory.ts:153 throw new Error('model selection is unavailable for addressed subagent sessions') ``` 同一文件的 `index.ts:9-11` 给出了原因:子代理会话绑定的模型 RPC 会绕过父级续跑路径去激活已持久化的历史, 所以 UI 层不提供入口。任何走 `session.selectModel` 那条 RPC 的实现都会撞上同一个拦截。 **结论**:子代理的模型与权限预设只能由本插件切换。插件写的是 `model/selection` 与 `permission/preset` 事件(交给宿主官方投影/折叠语义接管),不经过那条被禁的 RPC,因此不受该限制。 ## 二、模型切换的持久化机制(override) ### 官方链路的行为 官方模型选择链是「投影 `pending` → 会话请求头 → 部署默认」: 1. `selectModel` 调 `selectForNextRequest`,把选择写进 `model/selection` 事件并装入内存 `current`; 2. 请求组装时读的是 `selection.assembled`,它只在 `system-prompt/assemble` 时从 `current` 同步; 3. 请求发出后 `consumeSelection` 匹配则清空 `pending`; 4. 新回合若 `pending` 已清空,会沿 `request/header` 链回落,最终回到部署默认模型。 即:**官方切换只稳定一轮**。 ### 插件的 override 为跨轮持久,插件在 `agent/request` 瀑布流里按会话强制改写请求配置(每轮都改), 持久状态存在插件域 `memberModelOverrides`(`dsh_session_conductor.json`): ```js ctx.on("agent/request", async ({ agent }, next) => { const resolved = await next(); const override = await getMemberModelOverride(ctx, agent?.session?.id); return applyModelOverride(resolved, override); }); ``` ### 与官方界面的同步(1.3.18 起) override 优先级高于官方链路,早期版本因此有一个副作用:一旦写入,用户在主会话界面用官方选择器切换 完全无效 —— 界面显示新模型、请求一直走 override 里的旧模型,不报错、不提示、也没有清除入口。 现在的做法是**同步而非清除**:官方切换会往 `modelSelection` 投影写 `pending`;插件每轮请求前把 `pending` 与 override 比对,不一致就 `setMemberModelOverride` 成官方的新值。 - 官方选择立刻生效,同时保留 override 的跨轮持久能力(不会回落部署默认) - 同步只发生在**已有 override 的会话**上;没有 override 的会话交给官方链路,插件不干预 - 同步失败只记一条日志并退回旧 override,不打断请求 - 实现:`adoptOfficialSelection` / `readOfficialPendingSelection`(`lib/features/member-model/override.js`) ### 使用注意 - `set_member_model` 的批量「跟随主代理」(`auto` / 三参数全空)写入的是**当时**主代理用的模型; 之后主代理再换模型,这些成员不会自动跟着变(需再执行一次) - 想让某个会话彻底回到「官方说了算」,执行一次官方界面切换即可(override 会被同步成新值) ## 三、权限预设的悬空问题 子代理会话的 `permission/preset` 一旦指向当前不可用的值,该成员会永久唤不醒。 典型情形:`auto` 是「自动审查集成」的保留预设名。宿主 `permission-presets` 的 `pinInitialPermission()` 在会话发布前执行: ```js if (preset === AUTO_PRESET && this.autoAdmit === undefined) { throw new Error('permission: cannot restore preset "auto" without its active integration') } ``` 该异常被 subagent 的 `coldResume()` 统一包成 `subagent/not-resumable`,报错文本指不到权限层。 **判据**:会话日志最后一条 `permission/preset` 是 `auto`,且当前 `permissionPresets.catalog()` 的选项列表里没有 `auto`。 **修法**:追加一条 `permission/preset` 事件(独立 zstd 帧、带 checksum、长度 CAS、fsync), 靠宿主折叠语义「最后一条生效」。已有帧一字节不动,可删尾部帧回滚。 ## 四、成员换名的搬运细节 宿主的 Team 成员名是不可变身份字段: ```js // experimental/agent-team/src/projection.ts if (prior.name !== member.name || prior.provider !== member.provider || prior.context !== member.context) { throw new Error(`teammate "${member.id}" changed immutable identity fields`) } ``` 且只允许 provisioning → active 这一次写入,宿主侧没有任何 rename 接口。所以换名只能 「新建成员 + 搬会话内容」。 **为什么可行**:成员名存在 Lead 会话日志的 `team/member` 事件里,会话内容存在成员自己的日志里, 搬内容不触碰不可变字段。实测成员日志的事件里 0 处引用自己的 sessionId,因此**只换 header 就够**。 **搬运必须原样复制**:会话事件之间有大量跨事件引用 —— 顶层 `sourceEventSeqs`、 顶层 `surfaceOp{startSeq,endSeq}`、`data.messageSeqs`、`data.shadowedSeqs`、`data.headerSeq`, 以及 compaction 的 start → summary/prune → end 强配对。任何 seq 重排或事件增删都会让这些关系断裂, 宿主加载时判 corrupt。三种实际报错: ``` sourceEventSeqs ranges must contain unique earlier seqs session/title messageSeqs must cite distinct earlier human user/message events successful compaction/end requires one summary ``` **现行实现**:事件一条不改、seq 一个不动,只替换 header 身份;仅允许改写 descriptor 的 label / 模型路由(纯字符串,不参与引用);写前自检逐条与源对比,除 descriptor 可读字段外必须完全相等。 ## 五、其他实现细节 ### 损坏修复的四类判据 | 判据 | 处理 | |---|---| | seq-gap | 补/裁到连续 | | EIO 坏块 | 定位坏块并重建 | | 双格式(jsonl + zstd 并存) | 择一保留并清理另一份 | | zstd 帧级损坏 | 截断到最长可解码前缀(逐帧解码定位安全点、保留原始帧不重编码、备份后原子写回) | `supportsRepair` 的后端探测按 0.1.x 的接口判定,0.2.x 已移除该接口;现改用文件系统判据, 内容修复不可用时回退帧修复。 ### 记忆取回 按 zstd 帧流式读日志(常量内存),只认真实用户消息(排除插件自身注入,防反馈环), 同回合连发合并成一轮,注入总长与单条都有上限。触发条件是上下文用量明显下降(默认 ≥10%,可配)—— 压缩、截断、实例重启后快照未加载都算。 ### 跨会话消息的硬约束 禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条。全部走宿主官方 `prompt` API (幂等 `requestId`,不自己写会话日志)。 `list_sessions` 的 `fromIndex=true` 直接读落盘索引(秒回、不重建);默认刷新路径先等 3 秒拿热缓存结果, 超时就把同一次在途构建交给宿主后台 job 并返回 jobId。 ### 创建成员并选模型 宿主 `spawn_teammate` 没有 provider/model 参数(服务层 `SpawnTeammateRequest` 也只有 provider), 直接创建会让首轮用默认模型跑掉真实任务。本工具拆成四步:占位任务创建 → 立刻中断 → 等它真正空闲(用 `waitForChange` 等状态变化)→ 写 `model/selection` 后投递真实任务。 设模型失败时不投递(否则等于用默认模型白跑)。 `provider`/`model` 是模型 provider(agnes / wb / sa6400 …);创建成员另需子代理 provider (`ctx.subagents` 注册表,通常 `spawn`)—— 两者是不同命名空间,混用会报 `no subagent provider registered`。 ### 会话列表缓存 依赖 `sessionPersistence.list()` 返回的 `SessionPersistenceSnapshot` 里的 `revision` 作为 「日志变过没有」的变更令牌。0.2.1 起该 API 稳定提供 `revision`;更早版本 `list()` 只给裸 header, 插件优雅降级(功能正常,但每次列表构建都要重新解析日志)。