Jelajahi Sumber

修复非默认 Profile 工具目录并补充 Hermes 超时定位信息

Samien 1 bulan lalu
induk
melakukan
64095d4

+ 22 - 0
docs/verification/2026-09-19-profile-tools/README.md

@@ -0,0 +1,22 @@
+# 非默认 Profile 工具目录与超时诊断
+
+## 已确认的问题
+
+工具桥 `/bind` 的 HTTP 工作线程没有会话的 Profile 上下文。重建原生工具目录时,Hermes 按当前 home 选择插件注册表和缓存,导致非默认 Profile 的本轮工具已经注册、绑定也返回成功,但模型收到的目录没有这些工具。
+
+工具桥 1.2.4 在目录重建期间使用绑定记录中的 `profile_home`,并在成功或异常后恢复原上下文。这也覆盖目录刷新及子代理继承。各轮工具仍按原有授权过滤,不扩大权限。
+
+HTTP 超时报错现在区分读取配置、检查工具桥、绑定工具桥等阶段,并在请求中有合法 Profile 名称时显示它。请求时限和错误码保持原有行为;报错不包含完整请求、令牌或其他查询参数。
+
+## 验证
+
+- Python 工具桥及安装器测试:18 项通过。新增的 Profile 选择和异常恢复测试在修复前均失败。
+- `NODE_OPTIONS=--no-experimental-webstorage npx vitest run tests/server/upstream.test.ts tests/server/upstreamServiceSession.test.ts tests/server/workspaceTeamTools.test.ts tests/server/hermesBridge.test.ts --maxWorkers=4`:52 项通过。
+- `npx tsc --noEmit -p tsconfig.server.json`:通过。
+- 隔离的真实 Hermes Dashboard(本地源码提交 `e6f5ce2`):临时 home 中创建 `default` 和 `yaoer`,分别安装工具桥,使用本地模拟模型及工具回调。通过真实 HTTP、WebSocket 创建会话、绑定工具桥、提交消息、解绑。修复前只有 `default` 的模型请求包含 `yaoyao_fixture_ping_*`;修复后两者均包含,且都完成文字回复。未使用用户的真实 Profile、模型凭据或历史会话。
+
+## 仍需核查
+
+用户截图中的 `Unable to reach Hermes: request timed out` 在以上隔离环境中未复现。工具目录问题已确认并修复,但不能据此认定线上超时根因相同;仍需实际服务器的失败请求及 Hermes 日志。
+
+运行中的服务器需更新工具桥并重启 Hermes Dashboard 才能加载 1.2.4。本次只修改仓库,未部署或重启用户服务。

+ 3 - 1
integrations/hermes-bots-bridge/README.md

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.3。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.4。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
 
 ## Hermes 托管虚拟机会话
 
@@ -69,3 +69,5 @@ CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确
 1.2.2 对 `computer_policy.mode: isolated` 的会话应用 Hermes 现有的 `skip_context_files=True`、`load_soul_identity=False`,关闭自动上下文文件加载。策略只在工具桥内部应用,不增加 `session.create` 参数;`isolated_context_files: true` 表示已加载的插件支持此行为。
 
 1.2.3 将技能复盘与记忆隔离分开:`skill_learning: true` 表示插件允许受限的原生技能复盘。不会强制每次对话创建技能,也不会另建发布流程。
+
+1.2.4 修复非默认 Profile 在工具桥绑定、工具目录刷新及子代理继承时误读默认 Profile 工具目录的问题。目录重建使用绑定会话的 Profile,完成或失败后恢复调用方上下文。更新后需重启 Hermes Dashboard 服务,再发起新一轮对话。

+ 13 - 1
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -27,7 +27,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.2.3"
+PLUGIN_VERSION = "1.2.4"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -93,6 +93,18 @@ def register_context(ctx):
 
 
 def _refresh_agent_tools(agent, binding):
+    # Dashboard /bind runs outside the session's Profile context. Hermes keys
+    # plugin registries and tool-definition caches by the active home; without
+    # this scope a named Profile silently receives the default Profile catalog.
+    from hermes_constants import set_hermes_home_override, reset_hermes_home_override
+    context = set_hermes_home_override(binding.profile_home)
+    try:
+        _refresh_profile_agent_tools(agent, binding)
+    finally:
+        reset_hermes_home_override(context)
+
+
+def _refresh_profile_agent_tools(agent, binding):
     if binding.workspace_memory:
         _isolate_profile_memory(agent, binding)
     _isolate_profile_context(agent, binding)

+ 1 - 1
integrations/hermes-bots-bridge/plugin.yaml

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.2.3"
+version: "1.2.4"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 author: "Samien"

+ 47 - 1
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -5,6 +5,7 @@ import tempfile
 import threading
 import time
 import unittest
+from contextvars import ContextVar
 from pathlib import Path
 from types import SimpleNamespace
 from unittest.mock import patch
@@ -24,6 +25,7 @@ class ComputerPolicyTests(unittest.TestCase):
     def setUp(self):
         self.temp = tempfile.TemporaryDirectory()
         self.home = Path(self.temp.name)
+        self.profile_scope = ContextVar('fixture_home', default=self.home)
         self.agent = ReviewAgent(session_id='stored', enabled_toolsets=['all'], review_calls=[],
                                  skip_context_files=False, load_soul_identity=False,
                                  _background_review_agent=None, _background_review_run=None)
@@ -36,7 +38,11 @@ class ComputerPolicyTests(unittest.TestCase):
                                      computer_policy=dict(self.session['_yaoyao_computer_policy']))
         bridge._bindings.clear(); bridge._generations.clear(); bridge._children.clear()
         bridge._bindings['runtime'] = self.binding
-        self.patches = [patch.dict(sys.modules, {'agent.redact': SimpleNamespace(redact_sensitive_text=lambda text, **kw: text)}),
+        self.patches = [patch.dict(sys.modules, {
+                            'agent.redact': SimpleNamespace(redact_sensitive_text=lambda text, **kw: text),
+                            'hermes_constants': SimpleNamespace(get_hermes_home=self.profile_scope.get,
+                                set_hermes_home_override=self.profile_scope.set, reset_hermes_home_override=self.profile_scope.reset),
+                        }),
                         patch.object(bridge, '_server', return_value=self.server),
                         patch.object(bridge, '_check_session_mode'),
                         patch.object(bridge, '_transport_identity', return_value=self.binding.owner),
@@ -47,6 +53,46 @@ class ComputerPolicyTests(unittest.TestCase):
         for p in reversed(self.patches): p.stop()
         self.temp.cleanup()
 
+    def test_native_catalog_uses_binding_profile_and_restores_request_context(self):
+        current = ContextVar('fixture_profile', default=self.home)
+        constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
+        named = self.home / 'profiles' / 'yaoer'
+        self.binding.profile_home = named
+        self.binding.computer_policy = None
+        self.binding.toolset = 'yaoyao_turn_current'
+        other_turn = 'yaoyao_turn_other'
+        scopes = []
+
+        def definitions(enabled_toolsets, **kwargs):
+            scopes.append(current.get())
+            names = ['named_skill', self.binding.toolset, other_turn] if current.get() == named else ['default_skill']
+            return [{'function': {'name': name}} for name in names
+                    if enabled_toolsets == ['all'] or name in enabled_toolsets]
+
+        model_tools = SimpleNamespace(get_tool_definitions=definitions, get_toolset_for_tool=lambda name: name)
+        with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
+            bridge._refresh_agent_tools(self.agent, self.binding)
+        self.assertEqual(scopes, [named, named])
+        self.assertEqual(self.agent.valid_tool_names, {'named_skill', self.binding.toolset})
+        self.assertNotIn(other_turn, self.agent.enabled_toolsets)
+        self.assertEqual(current.get(), self.home)
+
+    def test_catalog_failure_restores_profile_context(self):
+        current = ContextVar('fixture_profile_failure', default=self.home)
+        constants = SimpleNamespace(set_hermes_home_override=current.set, reset_hermes_home_override=current.reset)
+        self.binding.profile_home = self.home / 'profiles' / 'yaoer'
+        self.binding.computer_policy = None
+
+        def fail(**kwargs):
+            self.assertEqual(current.get(), self.binding.profile_home)
+            raise RuntimeError('fixture catalog failed')
+
+        model_tools = SimpleNamespace(get_tool_definitions=fail, get_toolset_for_tool=lambda name: name)
+        with patch.dict(sys.modules, {'hermes_constants': constants, 'model_tools': model_tools}):
+            with self.assertRaisesRegex(RuntimeError, 'fixture catalog failed'):
+                bridge._refresh_agent_tools(self.agent, self.binding)
+        self.assertEqual(current.get(), self.home)
+
     def test_isolated_skills_and_vm_tools_use_native_catalog_but_host_operations_require_approval(self):
         for name in ['skills_list', 'skill_view', 'vision_analyze', 'yaoyao_computer_shell_aabbcc']:
             self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name=name, args={}))

+ 23 - 3
src/server/upstream.ts

@@ -114,6 +114,25 @@ export interface UpstreamRequestOptions {
   clientAddress?: string
 }
 
+function requestTimeoutMessage(path: string, options: UpstreamRequestOptions, seconds: number): string | undefined {
+  const stages: Record<string, string> = {
+    '/api/config': '读取配置',
+    '/api/plugins/yaoyao-bot-bridge/capabilities': '检查工具桥',
+    '/api/plugins/yaoyao-bot-bridge/bind': '绑定工具桥',
+    '/api/plugins/yaoyao-bot-bridge/unbind': '解除工具桥绑定',
+    '/api/plugins/yaoyao-bot-bridge/memory-extract': '提炼记忆',
+    '/api/plugins/yaoyao-bot-bridge/computer-file': '传输文件',
+  }
+  const stage = stages[path]
+  if (!stage) return undefined
+  const bodyProfile = options.body && typeof options.body === 'object' && 'profile' in options.body ? options.body.profile : undefined
+  const profile = options.search?.get('profile') ?? bodyProfile
+  // Only a validated Profile name may enter the error; never expose request
+  // bodies, query strings, bridge tokens or upstream credentials.
+  const scope = typeof profile === 'string' && /^[a-z0-9][a-z0-9_-]{0,63}$/.test(profile) ? `Profile「${profile}」` : ''
+  return `Hermes 请求超时(${seconds} 秒):${scope}${stage}。`
+}
+
 export class UpstreamClient {
   readonly readCache: UpstreamReadCache
   private loopback = new LoopbackTransport()
@@ -238,6 +257,7 @@ export class UpstreamClient {
 
     let response: Response
     let timeout: ReturnType<typeof setTimeout> | undefined
+    let timedOut = false
     const requestTimeout = path === '/api/plugins/yaoyao-bot-bridge/memory-extract' && options.method === 'POST' ? 110_000 : 30_000
     const controller = new AbortController()
     try {
@@ -250,13 +270,13 @@ export class UpstreamClient {
           signal: controller.signal,
         }),
         new Promise<never>((_resolve, reject) => {
-          timeout = setTimeout(() => { controller.abort(); reject(new Error('request timed out')) }, requestTimeout)
+          timeout = setTimeout(() => { timedOut = true; controller.abort(); reject(new Error('request timed out')) }, requestTimeout)
           timeout.unref()
         }),
       ])
     } catch (error) {
       const message = error instanceof Error ? error.message : 'connection failed'
-      throw new HttpError(502, `Unable to reach Hermes: ${message}`, 'upstream_unavailable')
+      throw new HttpError(502, (timedOut && requestTimeoutMessage(path, options, requestTimeout / 1000)) || `Unable to reach Hermes: ${message}`, 'upstream_unavailable')
     } finally {
       if (timeout) clearTimeout(timeout)
     }
@@ -277,7 +297,7 @@ export class UpstreamClient {
       try {
         const expired = new Promise<never>((_resolve, reject) => {
           bodyTimeout = setTimeout(() => {
-            reject(new HttpError(502, 'Hermes response timed out', 'upstream_unavailable'))
+            reject(new HttpError(502, requestTimeoutMessage(path, options, 30) ?? 'Hermes response timed out', 'upstream_unavailable'))
             void reader.cancel().catch(() => {})
           }, 30_000)
           bodyTimeout.unref()

+ 34 - 0
tests/server/upstream.test.ts

@@ -1,5 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import { CookieJar, UpstreamClient } from '../../src/server/upstream.js'
+import type { UpstreamRequestOptions } from '../../src/server/upstream.js'
 
 describe('upstream request boundary', () => {
   it('rejects a path that URL normalization would escape', async () => {
@@ -28,6 +29,39 @@ describe('upstream request boundary', () => {
   })
 })
 
+it.each([
+  {path:'/api/config', options:{search:new URLSearchParams({profile:'yaoer',token:'private-query'})}, stage:'Profile「yaoer」读取配置'},
+  {path:'/api/plugins/yaoyao-bot-bridge/capabilities', options:{search:new URLSearchParams({profile:'default'})}, stage:'Profile「default」检查工具桥'},
+  {path:'/api/plugins/yaoyao-bot-bridge/bind', options:{method:'POST',body:{profile:'yaoer',token:'private-bridge-token'}}, stage:'Profile「yaoer」绑定工具桥'},
+  {path:'/api/config', options:{search:new URLSearchParams({profile:'invalid\nprivate-value'})}, stage:'读取配置'},
+] satisfies Array<{path:string;options:UpstreamRequestOptions;stage:string}>)('identifies a timed-out $path without exposing request secrets',async({path,options,stage})=>{
+  vi.useFakeTimers()
+  const fetchImpl=vi.fn<typeof fetch>((_url,init)=>new Promise((_resolve,reject)=>{
+    init?.signal?.addEventListener('abort',()=>reject(new Error('aborted')),{once:true})
+  }))
+  const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),fetchImpl)
+  try{
+    const failed=expect(client.request(path,new CookieJar(),options)).rejects.toMatchObject({
+      code:'upstream_unavailable',message:`Hermes 请求超时(30 秒):${stage}。`,
+    })
+    await vi.advanceTimersByTimeAsync(30001);await failed
+    expect(fetchImpl).toHaveBeenCalledTimes(1)
+  }finally{client.close();vi.clearAllTimers();vi.useRealTimers()}
+})
+
+it('identifies the Profile when Hermes returns headers but the response body stalls',async()=>{
+  vi.useFakeTimers()
+  const cancel=vi.fn(),stream=new ReadableStream({cancel})
+  const client=new UpstreamClient(new URL('http://127.0.0.1:9119'),vi.fn<typeof fetch>(async()=>new Response(stream)))
+  try{
+    const failed=expect(client.request('/api/config',new CookieJar(),{search:new URLSearchParams({profile:'yaoer'})})).rejects.toMatchObject({
+      code:'upstream_unavailable',message:'Hermes 请求超时(30 秒):Profile「yaoer」读取配置。',
+    })
+    await vi.advanceTimersByTimeAsync(30001);await failed
+    expect(cancel).toHaveBeenCalledOnce()
+  }finally{client.close();vi.clearAllTimers();vi.useRealTimers()}
+})
+
 it('allows the Hermes memory helper to finish within its 90-second budget without changing normal request deadlines',async()=>{
   vi.useFakeTimers()
   const fetchImpl=vi.fn<typeof fetch>(()=>new Promise(done=>setTimeout(()=>done(new Response('{"text":"memory"}')),40000)))