Переглянути джерело

修复 Bot 冷恢复后模型设置失效并记录本地多模型实测

Samien 1 місяць тому
батько
коміт
79e65e3

+ 46 - 0
docs/verification/2026-09-20-local-hermes-models/README.md

@@ -0,0 +1,46 @@
+# 本地 Hermes 服务模式与 Bot 多模型实测
+
+2026-09-20,在本机已安装的夭夭 macOS App 中切换到服务器模式,进入 Bot 模式,使用第二个基础 Hermes「运维 / server」对应的现有「瑶儿」单聊。整个测试使用同一条对话,未新建替代 Bot,未使用假模型回复。
+
+## 启动准备
+
+- 原有 `com.samien.hermes-yaoyao` LaunchAgent 处于 disabled,导致 App 同步时出现 `launchctl bootstrap 未完成`。启用该既有服务后通过 App 重试成功。
+- 本地 Web 运行于 15300,Hermes Dashboard 运行于回环地址 9119;桌面启动偏好为本地服务器。
+- App 将原有 Web 0.4.25 同步至 0.4.49,按内置流程备份数据。
+- default、server 的既有工具桥从 1.2.3 更新至 1.3.0。安装器保留了原配置与插件备份。更新发生在 Dashboard 启动前。
+
+## 发现并修复的问题
+
+保存 `custom:tingly / GLM-5.3` 后,Bot 能回复,`config.set` 也确认了 GLM,但真正的 `session.info` 和 `message.complete.usage.model` 仍为 `GPT-5.6-terra`。
+
+Hermes 的冷恢复先返回会话,再异步初始化 Agent。显式 Provider 的模型设置可以在 Agent 未构建时返回成功,但随后工具桥绑定触发构建,恢复的旧运行配置覆盖了这些设置。仅检查设置回执不足以证明本轮使用了目标模型。
+
+修复将 Bot 模型、思考等级和速度的应用移到工具桥绑定、工作目录准备之后,仍在 `prompt.submit` 之前。此时不再使用初始化前的 `opened.info` 跳过模型应用。未确认、失败或需要费用确认的配置仍不提交聊天请求。
+
+新增回归用例模拟这个真实边界,验证初次会话、已有会话切换 Provider、恢复继承时,提交请求所用的实际模型、思考等级、速度。修复前该用例失败,修复后通过。
+
+## 真实模型结果
+
+测试口令为「青竹4729」,所有正常回复都来自同一条单聊。通过 Inspector 的实际会话 Provider、模型和完成事件的模型字段核对,未依赖模型自报身份。
+
+| 场景 | 实际模型 / Provider | 结果 |
+| --- | --- | --- |
+| 默认继承 | GPT-5.6-terra / custom:tingly | 正常返回「收到,青竹4729。」 |
+| 修复前选 GLM | 实际仍为 GPT-5.6-terra | 复现模型选择被冷恢复覆盖,不能算 GLM 通过 |
+| 修复后选 GLM | GLM-5.3 / custom:tingly | 4.36 秒完成,返回「青竹4729;42」,上下文保留 |
+| 切换 omni | omni / custom:tingly | 路由生效,上游 HTTP 429,`RequestBurstTooFast`;88.74 秒时主动停止重试,未获得正常回复 |
+| 跨 Provider 切换 | deepseek-v4-flash-free / opencode-free | 路由生效,21.05 秒后上游 HTTP 400:`Model is unavailable`;界面正确展示失败 |
+| 恢复继承并续聊 | GPT-5.6-terra / custom:tingly | 5.58 秒完成,返回「可以继续聊天,青竹4729。」 |
+
+脱敏后的设置回执、实际模型、回复与运行状态见 [runs.jsonl](runs.jsonl)。omni 的限流原因来自 Hermes server Profile 的错误日志;中断后的运行记录只保留「已停止」,因此不将它标记为聊天通过。
+
+## 校验与最终状态
+
+- `npm run typecheck`、`npm run desktop:build`、`git diff --check` 通过。
+- `botModelSettings`、`workspace`、`workspaceRemoteAgents`、`workspaceTeamTools` 共 139 项通过、2 项失败。失败为原有两个上下文统计断言;临时恢复修改前的 `workspaceRuntime.ts` 后,单独复跑仍出现相同失败。
+- 修复已通过内置本机服务同步流程部署到 15300;没有发布远端版本。
+- Bot 模型、思考等级、速度均恢复为继承;有效值为 `custom:tingly / GPT-5.6-terra / medium / normal`。
+- 两个 Hermes Profile 的 model 配置和 agent 默认配置与工具桥安装前备份一致;其他 Bot 的模型选择未修改。
+- App 保留本地服务器模式和 Bot 对话页,后台服务继续运行。
+
+本次验证范围为文本聊天、同会话模型切换、上下文连续性和上游错误后的恢复;没有将上游限流或下架的模型报告为成功。

+ 6 - 0
docs/verification/2026-09-20-local-hermes-models/runs.jsonl

@@ -0,0 +1,6 @@
+{"runId": "2f78714d-0e9c-4ecd-b675-77537c5d1233", "status": "complete", "durationSeconds": 3.2, "settings": null, "lastSessionInfo": {"model": "GPT-5.6-terra", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GPT-5.6-terra", "messages": [{"role": "user", "content": "本地 Bot 多模型聊天测试 A。不要调用工具。请记住测试口令「青竹4729」,本轮只回复:收到,青竹4729。", "status": "complete", "error": null}, {"role": "assistant", "content": "收到,青竹4729。", "status": "complete", "error": null}]}
+{"runId": "98fa69c4-8db4-48d3-a6fb-c23677b30813", "status": "complete", "durationSeconds": 3.16, "settings": {"revision": 40, "settings": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "GPT-5.6-terra", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GPT-5.6-terra", "messages": [{"role": "user", "content": "多模型测试 B:请说出上一轮让我记住的测试口令,并计算 17 + 25。不要调用工具,只按“口令;结果”的格式回复。", "status": "complete", "error": null}, {"role": "assistant", "content": "青竹4729;42", "status": "complete", "error": null}]}
+{"runId": "0b034222-292f-4144-8817-0b0037153629", "status": "complete", "durationSeconds": 4.36, "settings": {"revision": 40, "settings": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "GLM-5.3", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "GLM-5.3", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GLM-5.3", "messages": [{"role": "user", "content": "多模型修复复测 C(GLM):不要调用工具。说出此前的测试口令,并计算 23 + 19,只按“口令;结果”回复。", "status": "complete", "error": null}, {"role": "assistant", "content": "青竹4729;42", "status": "complete", "error": null}]}
+{"runId": "77a0822a-336c-43fc-a617-b317553f6523", "status": "interrupted", "durationSeconds": 88.74, "settings": {"revision": 41, "settings": {"provider": "custom:tingly", "model": "omni", "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "omni", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "omni", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": null, "messages": [{"role": "user", "content": "多模型测试 D(omni):不要调用工具。说出本次测试最初的口令,并计算 6 × 7,只按“口令;结果”回复。", "status": "complete", "error": null}, {"role": "assistant", "content": "", "status": "interrupted", "error": "已停止"}]}
+{"runId": "4f2df89d-199d-4d29-8fc8-de3704e61a85", "status": "failed", "durationSeconds": 21.05, "settings": {"revision": 42, "settings": {"provider": "opencode-free", "model": "deepseek-v4-flash-free", "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "opencode-free", "model": "deepseek-v4-flash-free", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "deepseek-v4-flash-free", "provider": "opencode-free", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "deepseek-v4-flash-free", "messages": [{"role": "user", "content": "跨 Provider 测试 E:不要调用工具。请说出最初的测试口令,并计算 84 ÷ 2,只按“口令;结果”回复。", "status": "complete", "error": null}, {"role": "assistant", "content": "HTTP 400: Error from provider (Console): Upstream request failed: Model is unavailable.", "status": "failed", "error": "HTTP 400: Error from provider (Console): Upstream request failed: Model is unavailable."}, {"role": "system", "content": "执行失败:HTTP 400: Error from provider (Console): Upstream request failed: Model is unavailable.", "status": "failed", "error": null}]}
+{"runId": "eb552716-b8cd-48aa-a771-f2cc9047fdf5", "status": "complete", "durationSeconds": 5.58, "settings": {"revision": 43, "settings": {"provider": null, "model": null, "reasoningEffort": null, "fastMode": null}, "applied": true, "effective": {"provider": "custom:tingly", "model": "GPT-5.6-terra", "reasoningEffort": "medium", "fastMode": "normal"}}, "lastSessionInfo": {"model": "GPT-5.6-terra", "provider": "custom:tingly", "profile_name": "server", "reasoning_effort": "medium", "service_tier": ""}, "completionModel": "GPT-5.6-terra", "messages": [{"role": "user", "content": "恢复默认模型测试 F:不要调用工具。请用一句中文确认可以继续聊天,并带上本次测试最初的口令。", "status": "complete", "error": null}, {"role": "assistant", "content": "可以继续聊天,青竹4729。", "status": "complete", "error": null}]}

+ 24 - 22
src/server/workspaceRuntime.ts

@@ -707,28 +707,6 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
       } else {
         if (opened.running) throw new HttpError(409, '上游会话仍在运行', 'session_busy')
         this.nodes.requireSource(owner, agent)
-        if (agent.modelSettings !== undefined) {
-          const entry = { method: 'bot.model-settings', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId }
-          const snapshot = { revision: agent.revision, settings: agent.modelSettings }
-          this.inspector?.record(owner, c.id, { ...entry, direction: 'request', data: snapshot })
-          try {
-            const resolution = await resolveBotModelSettings(target, agent.profile, agent.modelSettings)
-            const confirmed = agent.modelSettingsConfirmation === botModelTarget(resolution.effective)
-            if (resolution.confirmationMessage && !confirmed) throw new HttpError(409, '此模型需要确认,请打开 Bot 个人资料保存模型设置。', 'model_confirmation_required')
-            await applyBotModelSettings((method, params) => gateway.rpc(method, params), runtimeId, resolution.effective, confirmed, opened.info)
-            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: true, effective: resolution.effective } })
-          } catch (error) {
-            // Context-dependent native warnings cannot be known from the model
-            // catalogue. Bring them back to the profile's existing save flow.
-            if (error instanceof BotModelConfirmationError) {
-              const latest = this.store.require<Agent>(owner, 'agent', agent.id)
-              if (latest.revision === agent.revision) this.store.put(owner, 'agent', latest.id, { ...latest,
-                modelSettingsPendingConfirmation: { target: error.target, message: error.confirmationMessage } })
-            }
-            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: false, error: error instanceof Error ? error.message : '模型设置应用失败' } })
-            throw error
-          }
-        }
         const team=botCapabilities.tools&&!agent.temporaryGoalId&&!agent.remoteAgentId&&!this.store.require<Run>(owner,'run',run.runId).assignmentId
         const cloud=!!this.cloud?.selected(owner,agent)
         const plugins=!!this.plugins?.selected(owner,agent)
@@ -779,6 +757,30 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
           await toolLease.bind();if(settled)return await completion
         }
         await applyWorkingDirectory((method, params) => gateway.rpc(method, params), runtimeId, cwd, undefined, opened.info?.cwd)
+        // Binding initializes cold Hermes sessions; its persisted runtime can overwrite
+        // pre-build config acknowledgements. Apply Bot settings only after that boundary.
+        if (agent.modelSettings !== undefined) {
+          const entry = { method: 'bot.model-settings', agentId: agent.id, runId: run.runId, taskId: run.conversationTaskId }
+          const snapshot = { revision: agent.revision, settings: agent.modelSettings }
+          this.inspector?.record(owner, c.id, { ...entry, direction: 'request', data: snapshot })
+          try {
+            const resolution = await resolveBotModelSettings(target, agent.profile, agent.modelSettings)
+            const confirmed = agent.modelSettingsConfirmation === botModelTarget(resolution.effective)
+            if (resolution.confirmationMessage && !confirmed) throw new HttpError(409, '此模型需要确认,请打开 Bot 个人资料保存模型设置。', 'model_confirmation_required')
+            await applyBotModelSettings((method, params) => gateway.rpc(method, params), runtimeId, resolution.effective, confirmed)
+            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: true, effective: resolution.effective } })
+          } catch (error) {
+            // Context-dependent native warnings cannot be known from the model
+            // catalogue. Bring them back to the profile's existing save flow.
+            if (error instanceof BotModelConfirmationError) {
+              const latest = this.store.require<Agent>(owner, 'agent', agent.id)
+              if (latest.revision === agent.revision) this.store.put(owner, 'agent', latest.id, { ...latest,
+                modelSettingsPendingConfirmation: { target: error.target, message: error.confirmationMessage } })
+            }
+            this.inspector?.record(owner, c.id, { ...entry, direction: 'response', data: { ...snapshot, applied: false, error: error instanceof Error ? error.message : '模型设置应用失败' } })
+            throw error
+          }
+        }
         const trigger = this.store.require<Message>(owner, 'message', run.messageId)
         const attachmentRefs: string[] = []
         for (const file of trigger.attachments) {

+ 49 - 0
tests/server/workspace.test.ts

@@ -33,6 +33,7 @@ let requests: Array<{ method: string; params: Record<string, any> }>,
 let nodes: WorkspaceNodes
 let configuredCwds: Map<string, string>
 let rejectPrompt: string | undefined
+let observeGatewayRequest: ((frame: { method: string; params: Record<string, any> }) => void) | undefined
 let rejectedModelConfig: Record<string, unknown> | undefined
 let rejectedInterrupts: number
 let recoveryHistory: Array<{ role: string; content: string; tool_calls?: unknown }>
@@ -48,6 +49,7 @@ beforeEach(async () => {
   recoveryHistory = []
   storedByRuntime = new Map(); recoveryByStored = new Map()
   rejectPrompt = undefined
+  observeGatewayRequest = undefined
   rejectedModelConfig = undefined
   rejectedInterrupts = 0
   server = new WebSocketServer({ port: 0, host: '127.0.0.1' })
@@ -88,6 +90,7 @@ beforeEach(async () => {
     socket.on('message', (data) => {
       const frame = JSON.parse(String(data))
       requests.push(frame)
+      observeGatewayRequest?.(frame)
       const respond = (result: unknown) => socket.send(JSON.stringify({ id: frame.id, result }))
       if (frame.method === 'session.create' || frame.method === 'session.resume') {
         const runtimeId = randomUUID(), storedId = frame.method === 'session.resume' ? frame.params.session_id : randomUUID()
@@ -1801,3 +1804,49 @@ it('does not send a prompt when the model bridge cannot resolve the saved settin
   await vi.waitFor(()=>expect(store.require<WorkspaceRun>(owner,'run',run.id).status).toBe('failed'))
   expect(requests.filter(r=>r.method==='prompt.submit')).toHaveLength(0)
 })
+
+
+it('applies Bot settings to the live agent after a cold resume restores its persisted runtime during tool binding', async () => {
+  modelBridge()
+  const a = agent('冷启动模型切换'), c = direct(a.id)
+  const target = nodes.target(owner, 'local'), original = target.session.request.bind(target.session)
+  const models = new Map<string, Record<string, string>>()
+  const submitted: Array<Record<string, string>> = []
+  target.session.request = async (path, options) => {
+    if (path.endsWith('/capabilities')) return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ version: 1, ready: true, in_process: true, native_tools: true })) }
+    if (path.endsWith('/bind')) {
+      // Hermes acknowledges config.set before a deferred resume builds the agent.
+      // That build restores the stored runtime, replacing any premature settings.
+      const sid = (options?.body as any).session_id
+      models.set(sid, { model: 'persisted-model', reasoning: 'medium', fast: 'normal' })
+      return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({ ok: true, native_tools: true })) }
+    }
+    if (path.endsWith('/unbind')) return { status: 200, headers: new Headers(), body: Buffer.from('{"ok":true}') }
+    return original(path, options)
+  }
+  observeGatewayRequest = frame => {
+    if (frame.method !== 'config.set') return
+    const sid = frame.params.session_id, current = models.get(sid) ?? {}
+    current[frame.params.key] = frame.params.value
+    models.set(sid, current)
+  }
+  reply = (socket, params) => {
+    submitted.push({ ...models.get(params.session_id) })
+    socket.send(JSON.stringify({ method: 'event', params: { type: 'message.complete', session_id: params.session_id, payload: { text: '完成', status: 'complete' } } }))
+  }
+  configuredCwds.set('default', '/profile/working-directory')
+  for (const settings of [
+    { provider: 'openai', model: 'model-a', reasoningEffort: 'high', fastMode: 'fast' },
+    { provider: 'custom:provider', model: 'model-b', reasoningEffort: 'none', fastMode: 'cold' },
+    null,
+  ]) {
+    store.updateAgent(owner, a.id, { modelSettings: settings })
+    await finished(runtime.send(owner, c.id, { requestId: randomUUID(), content: '切换模型并保留上下文' }).id)
+  }
+  expect(submitted).toEqual([
+    { model: 'model-a --provider openai --session', reasoning: 'high', fast: 'fast' },
+    { model: 'model-b --provider custom:provider --session', reasoning: 'none', fast: 'cold' },
+    { model: 'base-model --provider base --session', reasoning: 'medium', fast: 'normal' },
+  ])
+  expect(requests.filter(r => r.method === 'session.resume')).toHaveLength(2)
+})