Jelajahi Sumber

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

Samien 1 bulan lalu
induk
melakukan
2a8f6396cd

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

@@ -1,6 +1,6 @@
 # 夭夭 Bot 的 Hermes 工具桥
 
-这是运行在 Hermes 内的用户插件,版本 1.2.2。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
+这是运行在 Hermes 内的用户插件,版本 1.2.3。它连接 Yaoyao 每轮授权的电脑、团队和应用工具,不修改 Hermes 核心。
 
 ## Hermes 托管虚拟机会话
 
@@ -12,6 +12,8 @@ Yaoyao 的虚拟机模式和本机协作模式都使用真实 Hermes Profile 会
 - 隔离模式在每次绑定时关闭 SOUL.md、AGENTS.md、CLAUDE.md、.cursorrules 等上下文文件的自动加载,并清除系统提示词缓存;恢复会话和子代理继承同一策略。本机协作模式及普通 Hermes 会话保持原行为。
 - 该策略保留 Profile 的模型、认证、技能加载和工具能力,以及 Bot 自己的设定、记忆和 Session 历史;不删除或改写 Profile 文件。显式读取文件仍遵循 Hermes 的工具权限,旧历史中已经出现的内容不会自动删除。
 - 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 会话不受影响。
 
 ## 安装
@@ -58,8 +60,12 @@ HTTP 使用 Hermes 自身认证。绑定身份必须与 WebSocket 的真实身
 
 真实测试使用已安装的 Tirith 检查器,默认查找 Hermes 主目录的 `bin/tirith`,也可通过 `YAOYAO_TIRITH_BIN` 指定,避免临时环境下载依赖造成超时。隔离模式关闭 YOLO,实际接收审批 RPC 并逐次回复 `once`;模型响应与授权文件使用测试数据。
 
+`tests/live/workspaceSkillLearning.live.test.ts` 验证真实 Hermes 在主轮次结束后创建、读取并更新自动生成的技能,随后由同 Profile 的另一个 Bot 复用。覆盖同模型及独立复盘模型,检查记忆和上下文隔离,以及复盘不污染主 Session。
+
 CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确启用所选插件及工具集,仅移除这个工具桥的显式禁用项。默认安装行为继续保留禁用设置。
 
 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.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.request
 import uuid
+from contextvars import ContextVar
 from dataclasses import dataclass, field
 from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.2.2"
+PLUGIN_VERSION = "1.2.3"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -47,6 +48,8 @@ _bindings: dict[str, "Binding"] = {}
 _generations: dict[str, tuple[dict, set[str]]] = {}
 _contexts: dict[Path, 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):
@@ -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):
+    if _skill_review_scope.get() is not None:
+        return
     if (function_name or tool_name) not in {"tool_search", "tool_describe", "yaoyao_tools"}:
         return
     try:
@@ -337,7 +342,7 @@ def capabilities(profile: str = "default") -> dict:
             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,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
-                    "isolated_context_files": True}
+                    "isolated_context_files": True, "skill_learning": True}
         finally:
             reset_hermes_home_override(context)
     except Exception as exc:
@@ -799,9 +804,102 @@ def _isolate_profile_memory(agent, binding):
     if manager:
         manager.shutdown_all()
     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):
+    scope = _skill_review_scope.get()
+    if scope is not None:
+        return _skill_review_directive(scope, session_id, tool_name)
     try:
         server = _server()
     except BridgeError:

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

@@ -1,4 +1,4 @@
 name: yaoyao-bot-bridge
-version: "1.2.2"
+version: "1.2.3"
 description: "夭夭 Bot 每轮授权的电脑、浏览器、手机、团队与应用工具桥"
 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)
 
 
+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):
     def setUp(self):
         self.temp = tempfile.TemporaryDirectory()
         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),
                         '_yaoyao_computer_policy': {'mode': 'isolated', 'hostAccess': False}}
         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.assertFalse(self.agent._memory_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(stopped, [True]); self.assertEqual(invalidated, [True])
         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.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'):
         agent = SimpleNamespace(session_id=session_id, enabled_toolsets=['skills'], disabled_toolsets=[],
                                 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)