# @richie.liu/dsh-hybrid-coder [English](README.md) | 中文 双模型路由策略插件:premium 模型负责规划与疑难修复,本地小模型(如本机 Ollama)负责常规实现步骤,本地模型连续失败时自动升级回 premium。 本插件是**路由策略**,不提供模型传输:模型请求仍由已注册的 LLM adapter(如 `@deepseek-ai/dsh-llm-deepseek`、`@deepseek-ai/dsh-llm-pi-ai`)发出。它在每一步的模型请求组装点改写目标 `provider`/`model`,并在工具执行失败链路达到阈值时切换路由。实验状态:公共契约可能变更,不随官方版本发布。 ## 安装 发布到 npm 后,在目标 profile 一键安装(包内 `cordis.patch.yml` 声明了 `dsh.bundle`,安装即成为激活的 profile 层): ```sh dsh plugin --profile web add @richie.liu/dsh-hybrid-coder ``` 或本地开发时以 tarball / overlay 方式启用(见下方「本地 provider 配置」)。默认配置示例指向 GLM + Ollama 路由,请按你的环境覆盖 `premium`/`local` 的 provider 与 model。 ## 工作原理 一次「step」是一次模型请求及其触发的工具调用。每一步组装请求时,插件按以下优先级决定路由: 1. **已升级(escalated)** → premium。上一轮本地模型连续失败触发的升级锁存未解除。 2. **plan mode 进行中** → premium。规划阶段始终使用强模型。 3. **其余情况** → local。 plan mode 状态直接从会话日志中的 `plan/mode` 事件折叠得到(`@deepseek-ai/dsh-plan-mode`),插件不另存规划状态。升级锁存与成功计数同样完全从日志折叠,因此 fork、resume、进程重启后路由决策一致恢复,没有进程内活态。 ### 升级(Strategy B) `tools/post-execute` 监听器观测每个工具执行结果。当**当前生效 provider 为 local** 时,统计「连续失败的工具执行」: - 失败 = `tool/result` 中工具结果块的 `isError: true`(`createToolResultMessage` 总持久化的权威失败信号)。`error` 字段仅在工具抛出带机器码的 HarnessError 时才存在,普通 `Error` 抛出的失败同样计入。 - 排除 `error.code` 为 `ABORTED` 或 `ABORTED_BEFORE_DISPATCH` 的取消结果(取消不是模型能力问题)。 - 计数在以下时刻清零:出现任意非错误工具结果、新 turn 开始(`turn/start`)、生效 provider 离开 local。 - 计数窗口为**当前 turn**:每个新用户 turn 给本地模型一次全新机会。 同一 turn 内连续失败数达到 `failureThreshold` 时: 1. 追加持久事件 `hybrid/route { to: 'premium', reason: 'tool-failures', turn, step }`; 2. 向下一步的收件箱注入一条升级指引消息(见下),其中包含最近若干条失败轨迹(已裁剪); 3. 后续 `agent/request` 折叠到该事件后路由到 premium。 ### 请求级回退 `agent/request-error` 监听器处理本地 provider 的**传输级**失败(`failure.code` 为 `TRANSPORT` 或 `TIMEOUT`,例如 Ollama 未启动、连接被拒)。此时先追加 `hybrid/route { to: 'premium', reason: 'request-failure' }`,再返回 `{ kind: 'retry' }` 让循环用 premium 重新发起同一步请求;其余错误码交由 `@deepseek-ai/dsh-llm-retry` 处理。 ### 降级 升级后,插件累计「干净的 premium step」:一个 step 生效 provider 为 premium、产生过 assistant 消息、且没有错误工具结果(纯文本回复也算成功)。累计达到 `premiumStepsBeforeDeescalation` 时,追加 `hybrid/route { to: 'local', reason: 'recovered' }`,路由恢复 local。降级不注入提示消息。 ### 系统提示身份同步 产品系统提示包含「由 `{{model}}` 模型驱动」之类的身份变量,其默认值来自声明的路由而非每步请求配置。若不处理,切到 local 的请求仍会宣称自己是 premium 模型。插件因此额外监听 `system-prompt/assemble`,把下一步实际路由的 `provider`/`model` 盖写到模板变量上,与模型选择保持一致。 中途因请求级回退在一步之内翻转路由时,该重试步的身份文本可能仍指向上一个路由;这是良性方向偏差(实际服务的是更强的 premium),记入 Known Limitations。 ## Config ```yaml - id: hybrid-coder name: '@richie.liu/dsh-hybrid-coder' config: premium: provider: glm model: glm-4-plus reasoningEffort: high # optional; unset keeps the provider default local: provider: ollama model: qwen2.5-coder:7b escalation: failureThreshold: 2 # consecutive failed tool executions in the current turn, >= 1 premiumStepsBeforeDeescalation: 2 # consecutive clean premium steps, >= 1 ``` 未知配置键在加载时失败。`premium.provider` 与 `local.provider` 必须是已注册的 provider 路由(见 `ctx.llm.listProviders()`);首次请求决策时若目标路由不存在,请求失败并明确报错,而不是静默回退。 `reasoningEffort` 仅作用于 premium 路由;local 路由始终清除继承的 effort,恢复其 provider 默认行为。 ## 本地 provider 配置(Ollama) 本地模型通过 `@deepseek-ai/dsh-llm-pi-ai` 的 hand-declared route 接入,无需写代码: ```yaml - id: llm-pi-ai name: '@deepseek-ai/dsh-llm-pi-ai' config: providers: ollama: displayName: Ollama (local) api: openai-completions apiKeyEnv: OLLAMA_API_KEY baseURL: http://localhost:11434/v1 models: - id: qwen3:4b-32k name: Qwen3 4B contextWindow: 32768 retryPolicy: mode: normal maxRetries: 0 ``` 实测三个必要设置(缺一不可): - **`apiKeyEnv` 必须声明**:pi-ai 的 `openai-completions` 协议要求凭据引用存在,否则请求以 `PI_AI_ERROR: No API key for provider` 失败。Ollama 忽略 Bearer 值,设一个占位环境变量(如 `OLLAMA_API_KEY=ollama`)即可。 - **上下文长度必须 ≥ 32768**:Ollama 默认 `num_ctx` 是 4096,装不下 harness 的系统提示与工具定义(实测约 13000 token),模型看不到工具、只会纯文本回复。profile 的 `contextWindow` 只是元数据,不改变 Ollama 行为;需要用 Modelfile 派生模型:`printf 'FROM qwen3:4b\nPARAMETER num_ctx 32768\n' > Modelfile && ollama create qwen3:4b-32k -f Modelfile`。 - **模型必须返回结构化 tool_calls**:实测 `qwen2.5-coder:7b`(声明支持 tools)在 Ollama 的 OpenAI 兼容端点上把工具调用以纯文本 JSON 输出(`tool_calls: null`),turn 会以纯文本直接结束;`qwen3:4b` 返回结构化调用,工作正常。接入新模型前先验证。 ### premium provider 配置(GLM) premium 路由以同样的 hand-declared 方式声明 —— 同一 adapter 下的另一个 provider,指向 OpenAI 兼容端点: ```yaml glm: displayName: Zhipu GLM api: openai-completions apiKeyEnv: GLM_API_KEY baseURL: https://open.bigmodel.cn/api/paas/v4 models: - id: glm-4-plus name: GLM-4-Plus contextWindow: 128000 retryPolicy: mode: normal maxRetries: 0 ``` 然后在 `hybrid-coder` config 中把 `premium.provider` / `premium.model` 指向它(如 `glm` / `glm-4-plus`)。key 通过 `GLM_API_KEY` 环境变量或 `~/.dsh/.credentials.yaml` 提供;`retryPolicy.maxRetries: 0` 的理由与 local 相同 —— 让本插件即时拥有传输故障转移。 ### 与 `llm-retry` 的组合契约 `@deepseek-ai/dsh-llm-retry` 默认注册在请求错误恢复链的外层。Ollama 死端点产生的 `TRANSPORT` 属于默认可重试错误码;若本地路由使用默认 `retryPolicy`(5 次退避),llm-retry 会先对死端点退避约 5 轮,才轮到本插件切换到 premium,故障转移很慢。 因此本地路由**必须**将 `retryPolicy.maxRetries` 设为 `0`(或从 `retryableCodes` 中移除 `TRANSPORT`),使 llm-retry 立即委派、由本插件即时拥有传输故障转移。这是 provider 自有配置(`retryPolicy` 属于各 provider 配置,不属于本插件 config),与架构中「providers own retryPolicy」的职责划分一致。 ## 持久事件 插件向 `SessionEventMap` 增加一个事件: ``` hybrid/route { to: 'premium' | 'local', reason: 'tool-failures' | 'request-failure' | 'recovered', turn: number, step: number } ``` - `tool-failures`:local 连续工具失败达阈值,升级; - `request-failure`:local 请求传输级失败,即时升级并重试; - `recovered`:升级后累计足够干净 premium step,降级回 local。 `turn`/`step` 命名事件发生时打开的 turn 与 step。该事件只记录粘性升级锁存;plan-mode 导致的 premium 路由不重复持久化(每步从 `plan/mode` 重新推导)。事件经 persistence catalog 生成器登记,随会话日志持久化、fork、resume。 `./invariant` 配套插件在事件追加前重放校验:载荷形状、turn/step 归属与单调性、合法转移(`to:'premium'` 只能从非升级态进入;`to:'local'` 只能从升级态恢复)。 ## Model Experience ### 升级指引 #### 模型看到什么 工具连续失败触发升级后,下一步模型收到一条 user 角色消息,来源标记为插件通知(`source.kind: 'plugin'`),内容为固定框架文本加最近失败轨迹。框架文本逐字如下: ```markdown The previous model made repeated failed tool calls. A stronger model is now handling the session. Diagnose the failure from the trajectory below and continue the task. Do not repeat the failing approach. Recent failed tool calls: ``` 其后逐条列出失败工具名与错误消息。轨迹只保留最近 4 条,每条错误消息截断;整条消息不超过 2000 UTF-8 字节。超出部分丢弃较早的失败条目。无失败轨迹时不出现该消息(该情况不会发生,因为升级即由失败触发)。 #### Token 效应 该消息仅在升级触发时追加一次,为条件性、有上限(≤2000 字节)的 append-only 输入。plan mode 路由、降级、请求级回退本身不添加模型 token。 #### KV Cache 效应 升级翻转替换请求的 provider/model 前缀,使该 provider 的缓存复用失效(物理上切换了模型端点);升级后的 premium 步骤之间共享稳定前缀,降级回 local 同理。指引消息追加在可复用历史之后,不改变此前已持久化的前缀。 ## Known Limitations and Deferred Work - **无 AST 骨架化上下文**:本仓库中文件内容只在模型主动调用读取工具后以工具结果进入模型,上下文组装阶段不挂载原始文件内容,因此原设计设想的「pre-step 骨架化文件」没有作用对象。超大工具结果由 `@deepseek-ai/dsh-spill-policy` 处理。未来可在 `tools/post-execute` 对 local 路由的读取结果做签名骨架替换(需引入 TypeScript 编译器依赖并覆盖完整语法表面),当前未实现。 - **无文件写入自动回滚**:插件不拥有文件系统事务;本地模型产生的错误写入由正常的工具结果反馈与升级流程纠正,不会自动撤销磁盘改动。 - **路由覆盖用户模型选择**:挂载本插件即意味着它拥有该 composition 中每个 agent 的路由;per-session 的显式模型选择仅在与配置路由对一致时保留。不提供「尊重显式选择」的开关(无当前消费者证据)。 - **重试步身份文本可能滞后**:请求级回退在一步之内翻转路由,该重试步的系统提示身份变量可能仍显示 local,而实际由 premium 服务。属良性方向,不影响结果。 - **实验性契约**:事件名、配置字段、指引文本在首次打 tag 发布前可能变更;持久日志不承诺跨版本兼容(与仓库 pre-release 立场一致)。