Răsfoiți Sursa

开启 Bot 独立技能复盘并保留记忆隔离

Samien 1 lună în urmă
părinte
comite
2a8f6396cd

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

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 # 夭夭 Bot 的 Hermes 工具桥
 
 
-这是运行在 Hermes 内的用户插件,版本 1.2.2。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.3。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
 
 
 ## Hermes 托管虚拟机会话
 ## Hermes 托管虚拟机会话
 
 
@@ -12,6 +12,8 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 - 隔离模式在每次绑定时关闭 SOUL.md、AGENTS.md、CLAUDE.md、.cursorrules 等上下文文件的自动加载,并清除系统提示词缓存;恢复会话和子代理继承同一策略。本机协作模式及普通 Hermes 会话保持原行为。
 - 隔离模式在每次绑定时关闭 SOUL.md、AGENTS.md、CLAUDE.md、.cursorrules 等上下文文件的自动加载,并清除系统提示词缓存;恢复会话和子代理继承同一策略。本机协作模式及普通 Hermes 会话保持原行为。
 - 该策略保留 Profile 的模型、认证、技能加载和工具能力,以及 Bot 自己的设定、记忆和 Session 历史;不删除或改写 Profile 文件。显式读取文件仍遵循 Hermes 的工具权限,旧历史中已经出现的内容不会自动删除。
 - 该策略保留 Profile 的模型、认证、技能加载和工具能力,以及 Bot 自己的设定、记忆和 Session 历史;不删除或改写 Profile 文件。显式读取文件仍遵循 Hermes 的工具权限,旧历史中已经出现的内容不会自动删除。
 - Skill 发现、读取、写入审核和学习由 Hermes 原生工具处理。依赖授权资源的步骤在 Hermes 完成;普通脚本和输入文件可显式传给 Linux 虚拟机。
 - Skill 发现、读取、写入审核和学习由 Hermes 原生工具处理。依赖授权资源的步骤在 Hermes 完成;普通脚本和输入文件可显式传给 Linux 虚拟机。
+- Bot 保留 Hermes 原生技能复盘触发条件(`skills.creation_nudge_interval`、`auxiliary.background_review.enabled`)。达到触发条件后,只用 `skills_list`、`skill_view`、`skill_manage` 复盘并维护当前 Profile 的技能,同 Profile 的其他 Bot 后续可以发现和复用。技能写入继续遵循 Hermes 原有策略。
+- 技能复盘使用 Hermes 原生后台生命周期,仅持有技能权限;主轮次结束后不再操作电脑、文件、协作或记忆工具。复盘不会写入主 Session,不恢复 Profile 记忆、OpenViking 读写或已关闭的上下文文件加载。
 - 停止、过期、换 Agent 或解绑后,旧电脑会话的原生工具与桥接工具均拒绝继续调用;普通 Profile 会话不受影响。
 - 停止、过期、换 Agent 或解绑后,旧电脑会话的原生工具与桥接工具均拒绝继续调用;普通 Profile 会话不受影响。
 
 
 ## 安装
 ## 安装
@@ -58,8 +60,12 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 
 
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
 
 
+`tests/live/workspaceSkillLearning.live.test.ts` 验证真实 Hermes 在主轮次结束后创建、读取并更新自动生成的技能,随后由同 Profile 的另一个 Bot 复用。覆盖同模型及独立复盘模型,检查记忆和上下文隔离,以及复盘不污染主 Session。
+
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
 
 
 1.2.1 将 Bot 记忆隔离放在受认证的 `/bind` 授权中(`workspace_memory: true`),在首个模型请求前关闭该会话的 Profile 记忆注入、原生记忆工具及后台复盘。不会向 Hermes 的严格 `session.create` 接口发送自定义记忆字段,也不修改 Profile 配置。安装后需重启 Dashboard 服务(默认 9119),消息网关的重启不负责重载这份插件。
 1.2.1 将 Bot 记忆隔离放在受认证的 `/bind` 授权中(`workspace_memory: true`),在首个模型请求前关闭该会话的 Profile 记忆注入、原生记忆工具及后台复盘。不会向 Hermes 的严格 `session.create` 接口发送自定义记忆字段,也不修改 Profile 配置。安装后需重启 Dashboard 服务(默认 9119),消息网关的重启不负责重载这份插件。
 
 
 1.2.2 对 `computer_policy.mode: isolated` 的会话应用 Hermes 现有的 `skip_context_files=True`、`load_soul_identity=False`,关闭自动上下文文件加载。策略只在工具桥内部应用,不增加 `session.create` 参数;`isolated_context_files: true` 表示已加载的插件支持此行为。
 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` 表示插件允许受限的原生技能复盘。不会强制每次对话创建技能,也不会另建发布流程。

+ 100 - 2
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -21,12 +21,13 @@ import urllib.error
 import urllib.parse
 import urllib.parse
 import urllib.request
 import urllib.request
 import uuid
 import uuid
+from contextvars import ContextVar
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
 from pathlib import Path
 from pathlib import Path
 from typing import Any
 from typing import Any
 
 
 VERSION = 1
 VERSION = 1
-PLUGIN_VERSION = "1.2.2"
+PLUGIN_VERSION = "1.2.3"
 def _plugin_fingerprint():
 def _plugin_fingerprint():
     root = Path(__file__).parent
     root = Path(__file__).parent
     digest = hashlib.sha256()
     digest = hashlib.sha256()
@@ -47,6 +48,8 @@ _bindings: dict[str, "Binding"] = {}
 _generations: dict[str, tuple[dict, set[str]]] = {}
 _generations: dict[str, tuple[dict, set[str]]] = {}
 _contexts: dict[Path, Any] = {}
 _contexts: dict[Path, Any] = {}
 _children: dict[str, tuple["Binding", Any]] = {}
 _children: dict[str, tuple["Binding", Any]] = {}
+_skill_review_scope: ContextVar[Any] = ContextVar("yaoyao_skill_review", default=None)
+_SKILL_REVIEW_TOOLS = frozenset({"skills_list", "skill_view", "skill_manage"})
 
 
 
 
 class BridgeError(Exception):
 class BridgeError(Exception):
@@ -158,6 +161,8 @@ def release_child(child_session_id=None, **kwargs):
 
 
 
 
 def refresh_catalog(session_id=None, function_name=None, tool_name=None, **kwargs):
 def refresh_catalog(session_id=None, function_name=None, tool_name=None, **kwargs):
+    if _skill_review_scope.get() is not None:
+        return
     if (function_name or tool_name) not in {"tool_search", "tool_describe", "yaoyao_tools"}:
     if (function_name or tool_name) not in {"tool_search", "tool_describe", "yaoyao_tools"}:
         return
         return
     try:
     try:
@@ -337,7 +342,7 @@ def capabilities(profile: str = "default") -> dict:
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
             return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
                     "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "computer_runtime_version": 2, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True}
+                    "isolated_context_files": True, "skill_learning": True}
         finally:
         finally:
             reset_hermes_home_override(context)
             reset_hermes_home_override(context)
     except Exception as exc:
     except Exception as exc:
@@ -799,9 +804,102 @@ def _isolate_profile_memory(agent, binding):
     if manager:
     if manager:
         manager.shutdown_all()
         manager.shutdown_all()
     agent._invalidate_system_prompt()
     agent._invalidate_system_prompt()
+    if agent is binding.agent:
+        _enable_skill_review(agent, binding)
+
+
+@dataclass
+class _SkillReviewScope:
+    parent: Any
+    binding: Binding
+    deadline: float
+    agent: Any = None
+    run: Any = None
+
+
+class _SkillReviewParent:
+    """Keep Hermes' review lifecycle on the real parent; constrain its detached fork.
+
+    Hermes registers the fork before its first model request. Intercept that
+    per-instance assignment, without patching Hermes classes or global config.
+    """
+    def __init__(self, scope):
+        object.__setattr__(self, "_scope", scope)
+
+    def __getattr__(self, name):
+        return getattr(self._scope.parent, name)
+
+    def __setattr__(self, name, value):
+        scope = self._scope
+        if name == "_background_review_run" and value is not None:
+            scope.run = value
+        if name == "_background_review_agent" and value is not None:
+            # Routed review models rebuild their prompt; same-model reviews may
+            # rebuild after compaction. Both must retain the parent's isolation.
+            value.skip_context_files = scope.parent.skip_context_files
+            value.load_soul_identity = scope.parent.load_soul_identity
+            value.skip_background_review = True
+            value._memory_store = value._memory_manager = None
+            value._memory_enabled = value._user_profile_enabled = False
+            from model_tools import get_tool_definitions
+            value.tools = [tool for tool in get_tool_definitions(enabled_toolsets=["skills"],
+                disabled_toolsets=scope.parent.disabled_toolsets, quiet_mode=True,
+                skip_tool_search_assembly=True) or [] if tool.get("function", {}).get("name") in _SKILL_REVIEW_TOOLS]
+            value.valid_tool_names = {tool["function"]["name"] for tool in value.tools}
+            value._invalidate_system_prompt()
+            scope.agent = value
+        setattr(scope.parent, name, value)
+
+
+def _enable_skill_review(agent, binding):
+    """Use Hermes' native trigger/queue/review, while allowing skills only."""
+    native = getattr(agent, "_spawn_background_review_now", None)
+    required = ("skip_context_files", "load_soul_identity", "_background_review_agent", "_background_review_run")
+    if (not all(hasattr(agent, name) for name in required) or not callable(native)
+        or (not callable(getattr(native, "__func__", None)) and not hasattr(agent, "_yaoyao_skill_review_native"))):
+        raise BridgeError("当前 Hermes 不支持工具桥的技能复盘", 409, "skill_review_unavailable")
+    agent._yaoyao_skill_review_binding = binding
+    if not hasattr(agent, "_yaoyao_skill_review_native"):
+        agent._yaoyao_skill_review_native = native.__func__
+
+        def skills_only(messages_snapshot, review_memory=False, review_skills=False, focus=None,
+                        task_cfg=None, _requeue_attempts=0, explicit=False):
+            if not review_skills:
+                return
+            scope = _SkillReviewScope(agent, agent._yaoyao_skill_review_binding,
+                                      time.monotonic() + MAX_LEASE_MS / 1000)
+            token = _skill_review_scope.set(scope)
+            try:
+                # Hermes copies ContextVars into its review/tool threads. The
+                # main turn never inherits this narrow post-turn permission.
+                return agent._yaoyao_skill_review_native(_SkillReviewParent(scope), messages_snapshot,
+                    review_memory=False, review_skills=True, focus=focus, task_cfg=task_cfg,
+                    _requeue_attempts=_requeue_attempts, explicit=explicit)
+            finally:
+                _skill_review_scope.reset(token)
+
+        agent._spawn_background_review_now = skills_only
+    agent.skip_background_review = False
+
+
+def _skill_review_directive(scope, session_id, tool_name):
+    from hermes_constants import get_hermes_home
+    if (time.monotonic() >= scope.deadline or scope.agent is None or scope.run is None
+        or getattr(scope.parent, "_background_review_agent", None) is not scope.agent
+        or getattr(scope.parent, "_background_review_run", None) is not scope.run
+        or scope.run.cancel_requested.is_set()
+        or session_id != scope.agent.session_id
+        or Path(get_hermes_home()).resolve() != scope.binding.profile_home.resolve()):
+        return {"action": "block", "message": "本次技能复盘已结束或 Profile 不匹配。"}
+    if tool_name not in _SKILL_REVIEW_TOOLS:
+        return {"action": "block", "message": "技能复盘仅允许 skills_list、skill_view 和 skill_manage;不要调用记忆、文件、电脑或协作工具。"}
+    return None
 
 
 
 
 def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
 def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
+    scope = _skill_review_scope.get()
+    if scope is not None:
+        return _skill_review_directive(scope, session_id, tool_name)
     try:
     try:
         server = _server()
         server = _server()
     except BridgeError:
     except BridgeError:

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

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

+ 68 - 2
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -15,11 +15,18 @@ sys.modules[spec.name] = bridge
 spec.loader.exec_module(bridge)
 spec.loader.exec_module(bridge)
 
 
 
 
+class ReviewAgent(SimpleNamespace):
+    def _spawn_background_review_now(self, messages_snapshot, **kwargs):
+        self.review_calls.append((messages_snapshot, kwargs, bridge._skill_review_scope.get()))
+
+
 class ComputerPolicyTests(unittest.TestCase):
 class ComputerPolicyTests(unittest.TestCase):
     def setUp(self):
     def setUp(self):
         self.temp = tempfile.TemporaryDirectory()
         self.temp = tempfile.TemporaryDirectory()
         self.home = Path(self.temp.name)
         self.home = Path(self.temp.name)
-        self.agent = SimpleNamespace(session_id='stored', enabled_toolsets=['all'])
+        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)
         self.session = {'agent': self.agent, 'session_key': 'stored', 'running': True, 'cwd': str(self.home),
         self.session = {'agent': self.agent, 'session_key': 'stored', 'running': True, 'cwd': str(self.home),
                         '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
                         '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
         self.server = SimpleNamespace(_sessions={'runtime': self.session}, _sessions_lock=threading.RLock())
         self.server = SimpleNamespace(_sessions={'runtime': self.session}, _sessions_lock=threading.RLock())
@@ -114,7 +121,7 @@ class ComputerPolicyTests(unittest.TestCase):
         self.assertIsNone(self.agent._memory_manager)
         self.assertIsNone(self.agent._memory_manager)
         self.assertFalse(self.agent._memory_enabled)
         self.assertFalse(self.agent._memory_enabled)
         self.assertFalse(self.agent._user_profile_enabled)
         self.assertFalse(self.agent._user_profile_enabled)
-        self.assertTrue(self.agent.skip_background_review)
+        self.assertFalse(self.agent.skip_background_review)
         self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
         self.assertEqual(self.agent.disabled_toolsets, ['terminal', 'memory'])
         self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
         self.assertEqual(stopped, [True]); self.assertEqual(invalidated, [True])
         self.assertTrue(other._memory_enabled)
         self.assertTrue(other._memory_enabled)
@@ -122,6 +129,65 @@ class ComputerPolicyTests(unittest.TestCase):
             self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
             self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool, args={})['action'], 'block')
         self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
         self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_view', args={}))
 
 
+    def test_skill_review_is_installed_once_and_never_runs_memory_review(self):
+        bridge._enable_skill_review(self.agent, self.binding)
+        wrapped = self.agent._spawn_background_review_now
+        bridge._enable_skill_review(self.agent, self.binding)
+        self.assertIs(wrapped, self.agent._spawn_background_review_now)
+        self.agent._spawn_background_review_now([], review_memory=True)
+        self.assertEqual(self.agent.review_calls, [])
+        self.agent._spawn_background_review_now([{'role': 'user', 'content': 'verified work'}], review_memory=True, review_skills=True)
+        self.assertEqual(len(self.agent.review_calls), 1)
+        _, kwargs, scope = self.agent.review_calls[0]
+        self.assertFalse(kwargs['review_memory'])
+        self.assertTrue(kwargs['review_skills'])
+        self.assertIs(scope.parent, self.agent)
+        self.assertIsNone(bridge._skill_review_scope.get())
+
+    def test_review_fork_keeps_isolation_and_only_skill_permission_after_turn_unbind(self):
+        self.agent.skip_context_files = True
+        self.agent.load_soul_identity = False
+        self.agent.disabled_toolsets = ['memory']
+        scope = bridge._SkillReviewScope(self.agent, self.binding, time.monotonic() + 60)
+        parent = bridge._SkillReviewParent(scope)
+        parent._background_review_run = SimpleNamespace(cancel_requested=threading.Event())
+        fork = self.context_agent()
+        fork._memory_store = fork._memory_manager = object()
+        fork._memory_enabled = fork._user_profile_enabled = True
+        tools = [{'function': {'name': name}} for name in ['skills_list', 'skill_view', 'skill_manage', 'memory']]
+        with patch.dict(sys.modules, {'model_tools': SimpleNamespace(get_tool_definitions=lambda **kw: tools)}):
+            parent._background_review_agent = fork
+        self.assertTrue(fork.skip_context_files)
+        self.assertFalse(fork.load_soul_identity)
+        self.assertTrue(fork.skip_background_review)
+        self.assertIsNone(fork._memory_manager)
+        self.assertIsNone(fork._memory_store)
+        self.assertFalse(fork._memory_enabled)
+        self.assertFalse(fork._user_profile_enabled)
+        self.assertIsNone(fork._cached_system_prompt)
+        self.assertEqual(fork.valid_tool_names, {'skills_list', 'skill_view', 'skill_manage'})
+        bridge._bindings.clear()
+        self.session['running'] = False
+        constants = SimpleNamespace(get_hermes_home=lambda: self.home)
+        with patch.dict(sys.modules, {'hermes_constants': constants}):
+            token = bridge._skill_review_scope.set(scope)
+            try:
+                self.assertIsNone(bridge.computer_directive(session_id='stored', tool_name='skill_manage'))
+                for tool in ['memory', 'provider_remember', 'read_file', 'terminal', 'yaoyao_computer_shell', 'workspace_send_to_agent']:
+                    self.assertEqual(bridge.computer_directive(session_id='stored', tool_name=tool)['action'], 'block')
+                self.assertEqual(bridge.computer_directive(session_id='other-session', tool_name='skill_manage')['action'], 'block')
+                constants.get_hermes_home = lambda: self.home / 'other-profile'
+                self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
+                constants.get_hermes_home = lambda: self.home
+                scope.run.cancel_requested.set()
+                self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
+                scope.run.cancel_requested.clear()
+                parent._background_review_agent = None
+                self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
+            finally:
+                bridge._skill_review_scope.reset(token)
+        self.assertEqual(bridge.computer_directive(session_id='stored', tool_name='skill_manage')['action'], 'block')
+
     def context_agent(self, session_id='stored'):
     def context_agent(self, session_id='stored'):
         agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
         agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
                                 skip_context_files=False, load_soul_identity=True,
                                 skip_context_files=False, load_soul_identity=True,

+ 117 - 0
tests/live/workspaceSkillLearning.live.test.ts

@@ -0,0 +1,117 @@
+// @vitest-environment node
+import {it,expect} from 'vitest'
+import {createServer} from 'node:http'
+import {spawn} from 'node:child_process'
+import {mkdtemp,mkdir,writeFile,readFile,cp,rm} from 'node:fs/promises'
+import {join,resolve} from 'node:path'
+import {tmpdir} from 'node:os'
+import {randomUUID} from 'node:crypto'
+import {DatabaseSync} from 'node:sqlite'
+import {WorkspaceStore} from '../../src/server/workspaceStore'
+import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
+import {UploadStore} from '../../src/server/uploads'
+import {UpstreamClient} from '../../src/server/upstream'
+import {UpstreamServiceSession} from '../../src/server/localAuth'
+import {ComputerRuntime,ComputerGateway} from '../../src/runner/worker/gateway'
+import type {WorkspaceNodes,GatewayTarget} from '../../src/server/workspaceGateway'
+import type {WorkspaceAgent} from '../../src/shared/workspace'
+
+const available=['YAOYAO_HERMES_PYTHON','YAOYAO_HERMES_SOURCE','YAOYAO_COMPUTER_IMAGE'].every(key=>!!process.env[key])
+it.skipIf(!available).each([false,true])('automatically saves a shared Profile skill after the Bot turn and another Bot reuses it (routed review: %s)',async(routed)=>{
+  const home=await mkdtemp(join(tmpdir(),'yaoyao-skill-learning-live-')),webHome=join(home,'web')
+  const python=process.env.YAOYAO_HERMES_PYTHON!,source=process.env.YAOYAO_HERMES_SOURCE!
+  const skillName='shared-fixture-skill',skillBody='---\nname: shared-fixture-skill\ndescription: Use when verifying a fixture command result.\n---\n# Shared fixture workflow\n\nRun the requested command, inspect the actual output, and report success only when it matches. SHARED_SKILL_PROOF\n'
+  const protectedFiles={'SOUL.md':'PRIVATE_SOUL_NOT_FOR_SKILL_REVIEW','AGENTS.md':'PRIVATE_CONTEXT_NOT_FOR_SKILL_REVIEW','memories/MEMORY.md':'PRIVATE_MEMORY_NOT_FOR_SKILL_REVIEW','memories/USER.md':'PRIVATE_USER_NOT_FOR_SKILL_REVIEW'}
+  await mkdir(join(home,'memories'));await mkdir(join(home,'plugins'))
+  for(const [file,content] of Object.entries(protectedFiles))await writeFile(join(home,file),content)
+  await writeFile(join(home,'.env'),'')
+  await cp(resolve('integrations/hermes-bots-bridge'),join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
+  let releaseReview!:()=>void,reviewCreated=false,reviewRead=false,reviewPatched=false,reviewRequests=0,reviewDone=false,reviewFailure=''
+  const reviewRelease=new Promise<void>(done=>{releaseReview=done}),requests:any[]=[]
+  const model=createServer(async(req,res)=>{
+    res.setHeader('Content-Type','application/json')
+    if(req.method==='GET'){res.end(JSON.stringify({data:[{id:'skill-fixture-model',context_length:64000}]}));return}
+    let raw='';for await(const part of req)raw+=part
+    const input=JSON.parse(raw);requests.push(input)
+    const names=(input.tools??[]).map((t:any)=>t.function.name),messages=JSON.stringify(input.messages)
+    const review=names.length>0&&names.every((name:string)=>['skills_list','skill_view','skill_manage'].includes(name))
+    let tool:string|undefined,args:any,content='SKILL_WORK_DONE'
+    if(review){
+      reviewRequests++;await reviewRelease
+      const result=input.messages.at(-1)
+      if(result?.role==='tool'&&typeof result.content==='string'){try{const body=JSON.parse(result.content);if(body.error)reviewFailure=result.content}catch{}}
+      if(!reviewCreated){reviewCreated=true;tool='skill_manage';args={operations:[{action:'create',name:skillName,content:routed?skillBody.replace('SHARED_SKILL_PROOF','SHARED_SKILL_OLD'):skillBody}]}}
+      else if(routed&&!reviewRead){reviewRead=true;tool='skill_view';args={name:skillName}}
+      else if(routed&&!reviewPatched){reviewPatched=true;tool='skill_manage';args={operations:[{action:'patch',name:skillName,old_string:'SHARED_SKILL_OLD',new_string:'SHARED_SKILL_PROOF'}]}}
+      else {content='SKILL_REVIEW_FINISHED';reviewDone=true}
+    }else if(messages.includes('复用同伴技能')){
+      if(!input.messages.some((m:any)=>m.role==='tool'&&JSON.stringify(m.content).includes('SHARED_SKILL_PROOF'))){tool='skill_view';args={name:skillName}}
+      else content='SECOND_BOT_REUSED_SKILL'
+    }else if(!input.messages.some((m:any)=>m.role==='tool'&&JSON.stringify(m.content).includes('SKILL_LEARNING_COMMAND_OK'))){
+      tool=names.find((name:string)=>name.startsWith('yaoyao_computer_shell_'));args={command:'printf SKILL_LEARNING_COMMAND_OK'}
+    }
+    const message=tool?{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:tool,arguments:JSON.stringify(args)}}]}:{role:'assistant',content}
+    const finish_reason=tool?'tool_calls':'stop'
+    if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:input.model,choices:[{index:0,delta:message,finish_reason}]})+'\n\ndata: [DONE]\n\n')}
+    else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:input.model,choices:[{index:0,message,finish_reason}],usage:{prompt_tokens:30,completion_tokens:8,total_tokens:38}}))
+  })
+  await new Promise<void>(done=>model.listen(0,'127.0.0.1',done))
+  const modelURL=`http://127.0.0.1:${(model.address() as {port:number}).port}/v1`
+  const config={model:{provider:'custom',default:'skill-fixture-model',base_url:modelURL,api_key:'fixture',context_length:64000},terminal:{cwd:home},memory:{memory_enabled:true,user_profile_enabled:true},skills:{creation_nudge_interval:1,write_approval:false},auxiliary:{background_review:{enabled:true,...(routed?{provider:'custom',model:'review-fixture-model',base_url:modelURL,api_key:'fixture'}:{})}},plugins:{enabled:['yaoyao-bot-bridge']},tools:{tool_search:{enabled:'off'}},dashboard:{turn_isolation:false},compression:{enabled:false}}
+  await writeFile(join(home,'config.yaml'),JSON.stringify(config))
+  const reservation=createServer();await new Promise<void>(done=>reservation.listen(0,'127.0.0.1',done))
+  const port=(reservation.address() as {port:number}).port;await new Promise<void>(done=>reservation.close(()=>done()))
+  const url=new URL(`http://127.0.0.1:${port}`);let logs=''
+  const child=spawn(python,['-m','hermes_cli.main','serve','--host','127.0.0.1','--port',String(port),'--skip-build','--isolated'],{cwd:home,env:{PATH:process.env.PATH,HOME:process.env.HOME,HERMES_HOME:home,PYTHONPATH:source,HERMES_YOLO_MODE:'1',HERMES_DASHBOARD_SESSION_TOKEN:'fixture-skill-learning-local-token',NO_PROXY:'127.0.0.1,localhost'},stdio:['ignore','pipe','pipe']})
+  child.stdout.on('data',c=>{logs=(logs+String(c)).slice(-12000)});child.stderr.on('data',c=>{logs=(logs+String(c)).slice(-12000)})
+  const client=new UpstreamClient(url),session=new UpstreamServiceSession(client,()=>undefined),target={url,client,session}
+  const store=new WorkspaceStore(webHome),uploads=new UploadStore(webHome),db=new DatabaseSync(':memory:')
+  const computer=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:'/must-not-run',hermesSource:'/must-not-read',hermesHome:'/must-not-read',network:'none'}},webHome,resolve('src/runner/worker/hermes_worker.py'),undefined,target)
+  const owner='fixture-owner',gateways:ComputerGateway[]=[]
+  const forAgent=(agent:WorkspaceAgent):GatewayTarget=>{
+    let current:ComputerGateway
+    return {...target,runner:{id:'fixture-runner',computer:true,hermesComputer:true,
+      open:async(onEvent,onDisconnect,scope)=>{
+        current=new ComputerGateway(computer,{agentId:agent.id,environmentId:agent.id,ownerKey:owner,hermesRuntime:true},scope!.workId,async()=>scope!.authorize(),async()=>({}),target)
+        gateways.push(current);current.onEvent=onEvent;current.onDisconnect=onDisconnect;await current.connect()
+        const gateway=current;return {rpc:(method,params)=>gateway.rpc(method,params),close:()=>{void gateway.close()}}
+      },
+      lease:async(input)=>{const gateway=current;gateway.installTeamLease(input.workId,input.catalog(),async(name,args,id)=>input.call(name,args,id),input.workspaceMemory===true);return {bind:async()=>{},dispose:async()=>gateway.removeTeamLease(input.workId)}},
+    }}
+  }
+  const nodes={requireSource(){},target:()=>target,targetForAgent:(_owner:string,agent:WorkspaceAgent)=>forAgent(agent)} as unknown as WorkspaceNodes
+  const runtime=new WorkspaceRuntime(store,nodes,uploads)
+  const send=async(agent:WorkspaceAgent,content:string)=>{
+    const conversation=store.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
+    const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content})
+    await expect.poll(()=>store.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/^(complete|failed|interrupted)$/)
+    const saved=store.require<any>(owner,'run',run.id);expect(saved.status,saved.error+'\n'+logs).toBe('complete')
+    return store.list<any>(owner,'message').find(m=>m.runId===run.id&&m.role==='assistant')?.content
+  }
+  try{
+    await expect.poll(async()=>{if(child.exitCode!==null)throw new Error(logs);try{return(await fetch(new URL('/api/status',url))).status}catch{return 0}},{timeout:45000}).toBe(200)
+    const first=store.createAgent(owner,{name:'skill-learner',profile:'default',execution:'computer',computer:'vm',vmExecution:'worker'})
+    expect(await send(first,'执行演示命令并核对输出')).toBe('SKILL_WORK_DONE')
+    // Learning must survive the foreground lease being revoked after delivery.
+    releaseReview()
+    await expect.poll(()=>reviewDone||!!reviewFailure,{timeout:45000,message:logs}).toBe(true)
+    expect(reviewFailure).toBe('')
+    expect(await readFile(join(home,'skills',skillName,'SKILL.md'),'utf8')).toContain('SHARED_SKILL_PROOF')
+    const second=store.createAgent(owner,{name:'skill-consumer',profile:'default',execution:'computer',computer:'vm',vmExecution:'worker'})
+    expect(await send(second,'复用同伴技能')).toBe('SECOND_BOT_REUSED_SKILL')
+    expect(reviewRequests).toBeGreaterThan(0)
+    const reviewCalls=requests.filter(r=>(r.tools??[]).length>0&&r.tools.every((t:any)=>['skills_list','skill_view','skill_manage'].includes(t.function.name)))
+    expect(reviewCalls.length).toBeGreaterThan(0)
+    if(routed)expect(reviewCalls.some(r=>r.model==='review-fixture-model')).toBe(true)
+    const sent=JSON.stringify(requests)
+    for(const [file,marker] of Object.entries(protectedFiles)){expect(sent).not.toContain(marker);expect(await readFile(join(home,file),'utf8')).toBe(marker)}
+    const native=new DatabaseSync(join(home,'state.db'),{readOnly:true})
+    try{expect((native.prepare("SELECT count(*) AS n FROM messages WHERE content LIKE '%SKILL_REVIEW_FINISHED%'").get() as {n:number}).n).toBe(0)}finally{native.close()}
+    expect(JSON.parse(await readFile(join(home,'config.yaml'),'utf8')).memory).toEqual(config.memory)
+  }finally{
+    releaseReview();runtime.close();await Promise.all(gateways.map(g=>g.close().catch(()=>{})));await computer.controls.close();await computer.pool.close()
+    db.close();uploads.close();store.close();client.close()
+    child.kill('SIGTERM');await new Promise<void>(done=>{if(child.exitCode!==null){done();return}const timer=setTimeout(()=>child.kill('SIGKILL'),5000);child.once('exit',()=>{clearTimeout(timer);done()})})
+    await new Promise<void>(done=>{model.close(()=>done());model.closeAllConnections()});await rm(home,{recursive:true,force:true})
+  }
+},120000)