Sfoglia il codice sorgente

fix(bot): select compact request history while preserving transcripts

Samien 2 settimane fa
parent
commit
2461dc49e7

+ 13 - 0
integrations/hermes-bots-bridge/README.md

@@ -102,3 +102,16 @@ CLI 增加 `--check` 可只读检查默认及命名 Profile;`--enable` 明确
 1.3.1 增加上述 Hermes 环境快照。Bot 的服务端连接状态取自本轮 Hermes 会话,客户端电脑清单仅表示 `desktop_*` 控制通道。服务器未打开客户端或客户端退出时,Hermes 原生能力不因此被标记离线;非回环地址也不会隐藏 Hermes 运行环境。旧工具桥没有快照时保持聊天与原生工具兼容,系统与目录信息显示未知。更新工具桥后需要重启 Hermes 才能加载新快照协议。
 
 发布时先更新工具桥与 Web 服务,在空闲时重启 Hermes Dashboard,再发布 Web/桌面端、iOS 和 Android 客户端。旧桥不能编辑新增设置,会明确提示升级;未设置 `modelSettings` 的旧 Bot 继续按原有继承路径运行。原生会话配置回执不兼容、模型无效或应用失败会停止本轮,不使用旧配置继续运行。
+# Bot context selection
+
+Bridge 1.4 exposes authenticated `/context` preparation and `yaoyao_history_read`.
+The caller registers an exact full prompt and its historical task-data form before
+submitting it. Hermes' request-only context selector keeps the newest policy in
+full and replaces only older prompts with verified provenance. Unknown/legacy
+messages are left intact; user section headings are never parsed for this purpose.
+Original session history and tool arguments remain intact. Older text tool
+results above 8,000 characters retain a head/tail excerpt and a paginated reference;
+identical older results above 1,500 characters share their full archived original.
+The two most recent user turns, images and non-text tool results remain complete.
+Archives and provenance are private, scoped to the Profile and session, and survive
+reconnection. Older bridges or runners keep sending the unmodified context.

+ 10 - 0
integrations/hermes-bots-bridge/__init__.py

@@ -6,6 +6,16 @@ from .bootstrap import runtime
 def register(ctx):
     bridge = runtime()
     bridge.register_context(ctx)
+    ctx.register_tool(
+        name='yaoyao_history_read', toolset='yaoyao_bot_bridge',
+        description='按历史工具结果中的引用读取当前 Bot 会话的完整原文。',
+        schema={'name': 'yaoyao_history_read', 'description': '读取被精简的历史工具原文;nextOffset 非空时继续分页。',
+                'parameters': {'type': 'object', 'properties': {
+                    'ref': {'type': 'string'}, 'offset': {'type': 'integer', 'minimum': 0},
+                    'limit': {'type': 'integer', 'minimum': 1, 'maximum': 12000}},
+                    'required': ['ref'], 'additionalProperties': False}},
+        handler=bridge.read_context,
+    )
     ctx.register_tool(
         name="yaoyao_tools",
         toolset="yaoyao_bot_bridge",

+ 60 - 3
integrations/hermes-bots-bridge/bridge_runtime.py

@@ -9,6 +9,7 @@ from __future__ import annotations
 import base64
 import hmac
 import hashlib
+import importlib.util
 import json
 import math
 import mimetypes
@@ -29,7 +30,7 @@ from pathlib import Path
 from typing import Any
 
 VERSION = 1
-PLUGIN_VERSION = "1.3.1"
+PLUGIN_VERSION = "1.4.0"
 def _plugin_fingerprint():
     root = Path(__file__).parent
     digest = hashlib.sha256()
@@ -105,6 +106,59 @@ def register_context(ctx):
         ctx.register_hook("pre_tool_call", computer_directive)
 
 
+def _bot_context(agent, home, stored):
+    module_name = '_yaoyao_bot_context_projection_v1'
+    module = sys.modules.get(module_name)
+    if module is None:
+        spec = importlib.util.spec_from_file_location(module_name, Path(__file__).with_name('context_projection.py'))
+        module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(module)
+        sys.modules[module_name] = module
+
+    def active():
+        try:
+            return _resolve(getattr(agent, 'session_id', '') or stored).agent is agent
+        except BridgeError:
+            return False
+    return module.install(agent, home, stored, active)
+
+
+def prepare_context(owner, body):
+    # Preparation happens BEFORE prompt.submit. Tool dispatch's _resolve()
+    # deliberately requires a running turn, so validate the idle grant directly.
+    server = _server()
+    sid, generation = _string(body, 'session_id'), _string(body, 'generation', 128)
+    with server._sessions_lock, _lock:
+        _purge(server)
+        binding, session = _bindings.get(sid), server._sessions.get(sid)
+        if binding is None or session is not binding.session or session.get('agent') is not binding.agent:
+            raise BridgeError('此轮上下文授权已失效')
+        _check_session_mode(session)
+        if (binding.owner != owner or binding.generation != generation or session.get('_finalized')
+                or _transport_identity(session.get('transport')) != owner
+                or _profile_home(session, binding.profile) != binding.profile_home):
+            raise BridgeError('当前登录不能修改此轮上下文')
+        if session.get('running'):
+            raise BridgeError('会话正在运行,不能修改本轮上下文', 409, 'session_busy')
+        try:
+            context = _bot_context(binding.agent, binding.profile_home, binding.stored_session_id)
+            context.register(body.get('prompt'), body.get('history_text'))
+        except ValueError as exc:
+            raise BridgeError(str(exc), 400, 'invalid_context') from None
+    return {'ok': True, 'context_projection_version': 1}
+
+
+def read_context(args, **kwargs):
+    binding = _resolve(kwargs.get('session_id', ''))
+    context = getattr(binding.agent, '_yaoyao_bot_context', None)
+    if context is None:
+        raise BridgeError('此会话没有可回查的工具历史', 404, 'history_missing')
+    try:
+        return json.dumps(context.read(args.get('ref'), args.get('offset', 0), args.get('limit', 6000)), ensure_ascii=False)
+    except (ValueError, FileNotFoundError):
+        raise BridgeError('历史引用或读取范围无效', 404, 'history_missing') from None
+
+
 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
@@ -405,6 +459,7 @@ def capabilities(profile: str = "default") -> dict:
                     "computer_runtime_version": 2, "execution_policy_version": 1, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
                     "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
                     "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1,
+                    "context_projection_version": 1,
                     "runtime_environment": _runtime_environment(profile, definitions)}
         finally:
             reset_hermes_home_override(context)
@@ -596,7 +651,8 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
             current.expires_at = max(current.expires_at, expiry)
             current.stored_session_id = stored
             return {"ok": True, "generation": generation, "expires_at": current.expires_at,
-                    "native_tools": bool(current.toolset), "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": current.workspace_memory}
+                    "native_tools": bool(current.toolset), "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": current.workspace_memory,
+                    "context_projection_version": 1}
         previous = _generations.get(sid)
         seen = previous[1] if previous and previous[0] is session else set()
         if generation in seen:
@@ -631,7 +687,8 @@ def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dic
                 _retire(binding)
                 raise
         return {"ok": True, "generation": generation, "expires_at": expiry, "native_tools": bool(binding.toolset),
-                "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": binding.workspace_memory}
+                "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": binding.workspace_memory,
+                "context_projection_version": 1}
 
 
 def unbind(owner: tuple[str, str], body: dict) -> dict:

+ 141 - 0
integrations/hermes-bots-bridge/context_projection.py

@@ -0,0 +1,141 @@
+"""Request-only Bot context selection; the durable Hermes transcript stays intact."""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import tempfile
+import threading
+from pathlib import Path
+from types import MethodType
+
+
+def _hash(text):
+    return hashlib.sha256(text.encode('utf-8')).hexdigest()
+
+
+def _atomic(path, text):
+    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.context-')
+    try:
+        with os.fdopen(fd, 'w', encoding='utf-8') as out:
+            os.fchmod(out.fileno(), 0o600)
+            out.write(text)
+        os.replace(tmp, path)
+    finally:
+        Path(tmp).unlink(missing_ok=True)
+
+
+class BotContext:
+    def __init__(self, home, session_id):
+        self.root = Path(home) / 'cache' / 'yaoyao-bot-context' / _hash(session_id)
+        self.root.mkdir(parents=True, exist_ok=True, mode=0o700)
+        self.lock = threading.RLock()
+        self.prompts = {}
+        path = self.root / 'prompts.json'
+        if path.exists():
+            data = json.loads(path.read_text(encoding='utf-8'))
+            if not isinstance(data, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in data.items()):
+                raise ValueError('Invalid Bot context provenance')
+            self.prompts = data
+        self.saved = set()
+        self.last_stats = {}
+
+    def register(self, prompt, history_text):
+        # Only the authenticated bridge may declare the historical form. Never
+        # parse section titles/markers out of user-authored or legacy messages.
+        if not isinstance(prompt, str) or not isinstance(history_text, str) or not prompt or not history_text:
+            raise ValueError('Invalid Bot context envelope')
+        if len(prompt) > 2_000_000 or len(history_text) > 2_000_000:
+            raise ValueError('Bot context envelope too large')
+        key = _hash(prompt)
+        with self.lock:
+            if key in self.prompts and self.prompts[key] != history_text:
+                raise ValueError('Conflicting Bot context provenance')
+            updated = {**self.prompts, key: history_text}
+            # The retained Hermes tail is bounded; old unknown rows fail open.
+            updated = dict(list(updated.items())[-2048:])
+            _atomic(self.root / 'prompts.json', json.dumps(updated, ensure_ascii=False))
+            self.prompts = updated
+
+    def _archive(self, text):
+        key = _hash(text)
+        if key not in self.saved:
+            path = self.root / (key + '.txt')
+            if not path.exists():
+                _atomic(path, text)
+            self.saved.add(key)
+        return key
+
+    def read(self, ref, offset=0, limit=6000):
+        if not isinstance(ref, str) or len(ref) != 64 or any(c not in '0123456789abcdef' for c in ref):
+            raise ValueError('Invalid history reference')
+        if type(offset) is not int or offset < 0 or type(limit) is not int or not 1 <= limit <= 12000:
+            raise ValueError('Invalid history range')
+        text = (self.root / (ref + '.txt')).read_text(encoding='utf-8')
+        return {'ref': ref, 'text': text[offset:offset + limit], 'totalChars': len(text),
+                'nextOffset': offset + limit if offset + limit < len(text) else None}
+
+    def select(self, messages, incoming_message=None):
+        with self.lock:
+            # Leave the latest full Bot prompt in place, including its current
+            # authorization, environment, task, facts and attachment boundaries.
+            matched = [(i, _hash(m['content'])) for i, m in enumerate(messages)
+                       if isinstance(m, dict) and m.get('role') == 'user'
+                       and isinstance(m.get('content'), str) and _hash(m['content']) in self.prompts]
+            latest = matched[-1][0] if matched else -1
+            incoming = incoming_message.get('content') if isinstance(incoming_message, dict) else None
+            incoming_key = _hash(incoming) if isinstance(incoming, str) else None
+            if incoming_key in self.prompts and matched and matched[-1][1] != incoming_key:
+                # Native ephemeral context may extend the current wire content.
+                # Its original incoming message still proves that a newer full
+                # policy is present; do not retain the previous policy as well.
+                latest = -1
+            changes = {}
+            for i, key in matched:
+                if i != latest:
+                    changes[i] = self.prompts[key]
+            users = [i for i, m in enumerate(messages) if isinstance(m, dict) and m.get('role') == 'user']
+            # Every result in the two newest user turns stays byte-identical.
+            cutoff = users[-2] if len(users) >= 2 else 0
+            seen = set()
+            for i in range(len(messages) - 1, -1, -1):
+                m = messages[i]
+                if not isinstance(m, dict) or m.get('role') != 'tool' or not isinstance(m.get('content'), str):
+                    continue
+                text = m['content']
+                identity = (m.get('name') or m.get('tool_name'), _hash(text))
+                duplicate = identity in seen and len(text) > 1500
+                seen.add(identity)
+                if i >= cutoff or (len(text) <= 8000 and not duplicate):
+                    continue
+                ref = self._archive(text)
+                notice = (f'\n[Earlier tool result: {len(text)} characters. '
+                          f'Full original: yaoyao_history_read(ref="{ref}", offset=0).]\n')
+                changes[i] = notice if duplicate else text[:2500] + notice + text[-1000:]
+            result = [{**m, 'content': changes[i]} if i in changes else m for i, m in enumerate(messages)]
+            prompt_replacements = len([i for i, _ in matched if i != latest])
+            self.last_stats = {'promptReplacements': prompt_replacements,
+                               'toolReplacements': len(changes) - prompt_replacements,
+                               'savedChars': sum(len(messages[i]['content']) - len(text) for i, text in changes.items())}
+            return result
+
+
+def install(agent, home, session_id, active):
+    existing = getattr(agent, '_yaoyao_bot_context', None)
+    if existing is not None and getattr(agent, '_yaoyao_bot_context_engine', None) is getattr(agent, 'context_compressor', None):
+        return existing
+    engine = getattr(agent, 'context_compressor', None)
+    original = getattr(engine, 'select_context', None)
+    if not callable(original):
+        raise ValueError('Hermes context selection is unavailable')
+    context = existing or BotContext(home, session_id)
+
+    def select_context(self, request_messages, **kwargs):
+        selected = original(request_messages, **kwargs)
+        source = selected if isinstance(selected, list) else request_messages
+        return context.select(source, kwargs.get('incoming_message')) if active() else selected
+
+    engine.select_context = MethodType(select_context, engine)
+    agent._yaoyao_bot_context = context
+    agent._yaoyao_bot_context_engine = engine
+    return context

+ 10 - 0
integrations/hermes-bots-bridge/dashboard/plugin_api.py

@@ -121,6 +121,16 @@ async def unbind(request: Request):
         return _error(exc)
 
 
+@router.post('/context')
+async def context(request: Request):
+    try:
+        identity = bridge.request_identity(request)
+        body = await _body(request, 8 * 1024 * 1024)
+        return await asyncio.to_thread(bridge.prepare_context, identity, body)
+    except bridge.BridgeError as exc:
+        return _error(exc)
+
+
 @router.post("/sampling")
 async def sampling(request: Request):
     try:

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

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

+ 15 - 0
integrations/hermes-bots-bridge/tests/test_computer_policy.py

@@ -54,6 +54,21 @@ class ComputerPolicyTests(unittest.TestCase):
         for p in reversed(self.patches): p.stop()
         self.temp.cleanup()
 
+    def test_context_preparation_checks_owner_generation_and_busy_state(self):
+        body = {'session_id':'runtime','generation':'generation','prompt':'full rules','history_text':'original user'}
+        with self.assertRaises(bridge.BridgeError):
+            bridge.prepare_context(('wrong','account'), body)
+        with self.assertRaises(bridge.BridgeError):
+            bridge.prepare_context(self.binding.owner, {**body,'generation':'wrong'})
+        with self.assertRaises(bridge.BridgeError) as busy:
+            bridge.prepare_context(self.binding.owner, body)
+        self.assertEqual(busy.exception.code, 'session_busy')
+        self.session['running']=False
+        self.agent.context_compressor=SimpleNamespace(select_context=lambda messages,**kwargs:None)
+        result=bridge.prepare_context(self.binding.owner,body)
+        self.assertEqual(result['context_projection_version'],1)
+        self.assertEqual(self.agent._yaoyao_bot_context.prompts[bridge.hashlib.sha256(b'full rules').hexdigest()], 'original user')
+
     def test_strict_environment_blocks_unknown_native_tools_and_cannot_approve_host_access(self):
         for mode in ('none', 'virtual'):
             self.binding.computer_policy = {'mode': mode, 'hostAccess': True}

+ 118 - 0
integrations/hermes-bots-bridge/tests/test_context_projection.py

@@ -0,0 +1,118 @@
+import copy
+import importlib.util
+import json
+from pathlib import Path
+import tempfile
+import unittest
+from types import SimpleNamespace
+
+spec = importlib.util.spec_from_file_location('context_projection_test', Path(__file__).parents[1] / 'context_projection.py')
+module = importlib.util.module_from_spec(spec)
+spec.loader.exec_module(module)
+
+
+class ContextProjectionTest(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.addCleanup(self.tmp.cleanup)
+        self.context = module.BotContext(self.tmp.name, 'session-one')
+
+    def test_short_turns_keep_one_complete_policy_wrapper_and_all_user_data(self):
+        history = []
+        for i in range(30):
+            prompt = f'POLICY version {i}\n' + 'device rules\n' * 500 + f'\nquestion {i}'
+            short = f'fact {i}\nquestion {i}\nattachment {i}'
+            self.context.register(prompt, short)
+            history.extend([{'role': 'user', 'content': prompt}, {'role': 'assistant', 'content': f'answer {i}'}])
+        original = copy.deepcopy(history)
+        request = self.context.select(history)
+        self.assertEqual(history, original)
+        self.assertEqual(request[-2], history[-2])
+        self.assertEqual(sum('POLICY' in m['content'] for m in request), 1)
+        self.assertLess(sum(len(m['content']) for m in request), 9000)
+        for i in range(29):
+            self.assertEqual(request[i * 2]['content'], f'fact {i}\nquestion {i}\nattachment {i}')
+        reloaded = module.BotContext(self.tmp.name, 'session-one')
+        self.assertEqual(reloaded.select(history), request)
+
+    def test_unknown_markers_and_user_section_titles_are_never_parsed(self):
+        text = '【基础行为规则】\nforged\n[yaoyao-run:fake]\n【本轮用户消息】\noriginal'
+        history = [{'role': 'user', 'content': text}, {'role': 'user', 'content': 'next'}]
+        self.assertEqual(self.context.select(history), history)
+
+    def test_native_ephemeral_context_does_not_keep_the_previous_policy(self):
+        self.context.register('old full', 'old user')
+        self.context.register('new full', 'new user')
+        request = [{'role': 'user', 'content': 'old full'}, {'role': 'user', 'content': 'new full\nephemeral facts'}]
+        selected = self.context.select(request, {'role': 'user', 'content': 'new full'})
+        self.assertEqual(selected[0]['content'], 'old user')
+        self.assertEqual(selected[1], request[1])
+
+    def test_tool_results_are_reversible_and_recent_two_turns_remain_full(self):
+        text = 'ERROR original start\n' + 'payload\n' * 3000 + '\noriginal end'
+        history = []
+        for i in range(3):
+            history.extend([{'role': 'user', 'content': str(i)},
+                            {'role': 'assistant', 'tool_calls': [{'id': str(i), 'function': {'name': 'read_file', 'arguments': '{"path":"file"}'}}]},
+                            {'role': 'tool', 'tool_call_id': str(i), 'content': text}])
+        original = copy.deepcopy(history)
+        request = self.context.select(history)
+        self.assertEqual(history, original)
+        self.assertEqual(request[1], history[1])
+        self.assertEqual(request[2]['tool_call_id'], '0')
+        self.assertLess(len(request[2]['content']), 500)
+        self.assertIn('yaoyao_history_read', request[2]['content'])
+        self.assertEqual(request[5], history[5])
+        self.assertEqual(request[8], history[8])
+        ref = module._hash(text)
+        self.assertEqual(self.context.read(ref, 0, 12000)['text'], text[:12000])
+        self.assertEqual(self.context.read(ref, 12000, 12000)['text'], text[12000:24000])
+        self.assertEqual((self.context.root / (ref + '.txt')).read_text(), text)
+        self.assertEqual((self.context.root / (ref + '.txt')).stat().st_mode & 0o777, 0o600)
+
+    def test_large_distinct_result_keeps_head_tail_and_does_not_strip_images(self):
+        text = 'BEGIN' + 'x' * 10000 + 'END'
+        image = [{'type': 'image_url', 'image_url': {'url': 'data:image/png;base64,original'}}]
+        history = [{'role': 'user', 'content': 'old'}, {'role': 'tool', 'content': text},
+                   {'role': 'tool', 'content': image}, {'role': 'user', 'content': 'recent'},
+                   {'role': 'user', 'content': 'current'}]
+        result = self.context.select(history)
+        self.assertTrue(result[1]['content'].startswith('BEGIN'))
+        self.assertTrue(result[1]['content'].endswith('END'))
+        self.assertEqual(result[2]['content'], image)
+
+    def test_references_are_session_scoped_and_reject_paths(self):
+        ref = self.context._archive('original')
+        other = module.BotContext(self.tmp.name, 'session-two')
+        with self.assertRaises(FileNotFoundError):
+            other.read(ref)
+        for bad in ['../prompts.json', 'a' * 63, '../' + 'a' * 61]:
+            with self.assertRaises(ValueError):
+                self.context.read(bad)
+
+    def test_installed_selector_respects_existing_engine_and_active_binding(self):
+        class Engine:
+            def select_context(self, messages, **kwargs):
+                return [{**messages[0], 'extra': 'existing engine'}, *messages[1:]]
+        agent = SimpleNamespace(context_compressor=Engine())
+        active = [True]
+        context = module.install(agent, self.tmp.name, 'engine-session', lambda: active[0])
+        context.register('full old', 'short old')
+        context.register('full new', 'short new')
+        history = [{'role': 'user', 'content': 'full old'}, {'role': 'user', 'content': 'full new'}]
+        selected = agent.context_compressor.select_context(history)
+        self.assertEqual(selected[0]['content'], 'short old')
+        self.assertEqual(selected[0]['extra'], 'existing engine')
+        active[0] = False
+        self.assertEqual(agent.context_compressor.select_context(history)[0]['content'], 'full old')
+        self.assertIs(module.install(agent, self.tmp.name, 'engine-session', lambda: True), context)
+
+    def test_provenance_conflict_does_not_replace_accepted_input(self):
+        self.context.register('full', 'user original')
+        with self.assertRaises(ValueError):
+            self.context.register('full', 'different user')
+        self.assertEqual(self.context.prompts[module._hash('full')], 'user original')
+
+
+if __name__ == '__main__':
+    unittest.main()

+ 7 - 1
src/runner/agent.ts

@@ -68,7 +68,7 @@ export class RunnerAgent {
     const toolId=body&&typeof body==='object'&&'toolId' in body?body.toolId:undefined
     const response=await (isLocalAuthorizationTarget(url)&&this.fetchImpl===fetch?this.controlTransport.fetch.bind(this.controlTransport):this.fetchImpl)(url,{
       method:body===undefined?'GET':'POST',redirect:'error',signal:AbortSignal.any([this.controlAbort.signal,AbortSignal.timeout(path==='desktop'?75000:path==='tool'&&typeof toolId==='string'&&(toolId.startsWith('managed_browser_')||toolId.startsWith('credential_'))?600000:25000)]),
-      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':'')+',credential-ref-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
+      headers:{Authorization:`Bearer ${this.config.token}`,'x-runner-instance':this.instance,'x-runner-protocol':'1','x-runner-features':(this.computers?'execution-context-v1,vm-egress-v1,workspace-memory-bind-v1,hermes-computer-v2,profile-computer-v1,host-computer-tools-v1,file-transfer-v1,idle-stop-policy-v1,computer-worker-v1,artifact-chunks-v1,computer-control-v1,shared-computer-v1,local-vm-v1'+(this.computers.provider.fixedCapacity?',compose-desktops-v1':',helper-retirement-v1,image-options-v1')+(this.computers.config.imageId!==UNCONFIGURED_COMPUTER_IMAGE?',image-ready-v1':''):'execution-context-v1,workspace-memory-bind-v1')+(this.browsers?',managed-browser-setup-v1,managed-browser-retention-v1':',managed-browser-disabled-v1')+(this.browsers?.available?',managed-browser-v1':'')+',credential-ref-v1,bot-context-v1',...(this.serverEpoch?{'x-runner-epoch':this.serverEpoch}:{}),'Content-Type':'application/json',...(path==='poll'&&!this.connected?{'x-runner-reset':'1'}:{})},
       ...(body===undefined?{}:{body:JSON.stringify(body)}),
     })
     if(!response.ok){
@@ -305,6 +305,12 @@ export class RunnerAgent {
       if(typeof update.storedId!=='string')throw new Error('存储会话编号无效')
       lease.session.storedId=update.storedId;await lease.lease.bind();return {ok:true}
     }
+    if(command.kind==='lease.context') {
+      const lease=this.leases.get(leaseId)
+      if(!lease||!this.connections.has(lease.connectionId))throw new HttpError(410,'工具授权已关闭','runner_lease_expired')
+      if(typeof p.prompt!=='string'||typeof p.historyText!=='string')throw new Error('上下文格式无效')
+      return {enabled:await lease.lease.prepareContext?.(p.prompt,p.historyText)??false}
+    }
     if(command.kind==='lease.close') {
       this.closedLeases.set(leaseId,Date.now()+60000)
       const lease=this.leases.get(leaseId);this.leases.delete(leaseId)

+ 9 - 3
src/server/runnerHub.ts

@@ -243,7 +243,13 @@ export class RunnerHub {
             await this.request(record.id,'lease.create',{leaseId:id,profile:options.profile,workId:options.workId,session:options.session(),catalog:options.catalog(),workspaceMemory:options.workspaceMemory===true,computerPolicy:options.computerPolicy})
             options.assertActive()
             if(options.signal.aborted)throw new Error('执行已停止')
-            return {bind:async()=>{options.assertActive();await this.request(record.id,'lease.bind',{leaseId:id,session:options.session()});options.assertActive()},dispose} satisfies WorkspaceToolLease
+            return {bind:async()=>{options.assertActive();await this.request(record.id,'lease.bind',{leaseId:id,session:options.session()});options.assertActive()},dispose,
+              prepareContext:async(prompt,historyText)=>{
+                options.assertActive()
+                if(!this.online.get(record.id)?.features.includes('bot-context-v1'))return false
+                const result=await this.request(record.id,'lease.context',{leaseId:id,prompt,historyText}) as {enabled?:boolean}
+                options.assertActive();return result.enabled===true
+              }} satisfies WorkspaceToolLease
           } catch(error){await dispose();throw error}
         },
       }}
@@ -325,7 +331,7 @@ export class RunnerHub {
         retired.add(previous.instance);this.retired.set(record.id,retired);this.disconnect(record.id);previous=undefined
       }
       if(match[2]!=='poll'&&ctx.get('x-runner-epoch')!==previous?.epoch)throw new HttpError(409,'执行连接代次已改变','runner_epoch_changed')
-      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['execution-context-v1','vm-egress-v1','workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1','credential-ref-v1'].includes(value));this.online.set(record.id,state)
+      const state=this.online.get(record.id)??{instance,seen:Date.now(),features:[],epoch:`${this.epoch}:${randomUUID()}`};state.seen=Date.now();if(ctx.get('x-runner-features'))state.features=ctx.get('x-runner-features').split(',').filter(value=>['execution-context-v1','vm-egress-v1','workspace-memory-bind-v1','hermes-computer-v2','profile-computer-v1','host-computer-tools-v1','file-transfer-v1','idle-stop-policy-v1','computer-worker-v1','artifact-chunks-v1','helper-retirement-v1','computer-control-v1','shared-computer-v1','local-vm-v1','image-options-v1','image-ready-v1','compose-desktops-v1','managed-browser-v1','managed-browser-setup-v1','managed-browser-disabled-v1','managed-browser-retention-v1','credential-ref-v1','bot-context-v1'].includes(value));this.online.set(record.id,state)
       ctx.set('Cache-Control','no-store')
       if(match[2]==='poll') {
         if(ctx.method!=='GET')throw new HttpError(405,'仅允许 GET','method_not_allowed')
@@ -399,7 +405,7 @@ export class RunnerHub {
         const pending=this.pending.get(record.id)?.get(body.id),command=pending?.command
         let allowed=!!command&&command.expiresAt>Date.now()&&pending!.valid()&&this.networkCommandAllowed(record.id,command.kind,command.payload)
         if(command?.kind==='gateway.rpc'||command?.kind==='gateway.open'){const connection=this.connections.get(String(command.payload.connectionId));allowed&&=connection?.runnerId===record.id&&(command.kind==='gateway.rpc'&&['session.interrupt','session.close','session.active_list'].includes(String(command.payload.method))||connection.valid())}
-        if(command?.kind==='lease.create'||command?.kind==='lease.bind') {
+        if(command?.kind==='lease.create'||command?.kind==='lease.bind'||command?.kind==='lease.context') {
           const lease=this.leases.get(String(command.payload.leaseId))
           try {if(lease?.runnerId!==record.id)allowed=false;else lease.input.assertActive()}catch{allowed=false}
         }

+ 11 - 0
src/server/workspacePrompt.ts

@@ -36,6 +36,17 @@ function section(title: string, lines: Array<string | undefined>): string {
   return content ? `【${title}】\n${content}` : ''
 }
 
+/** Retain task data and facts, without stale copies of the turn's policy wrapper. */
+export function workspacePromptHistory(input: WorkspacePromptInput): string {
+  return [
+    input.memory ? section('历史 Bot 事实', [input.memory]) : '',
+    input.run.internalInstruction ? section('历史任务要求', [input.run.internalInstruction]) : '',
+    input.marker,
+    section(input.contentKind === 'user' ? '本轮用户消息' : '本轮对话上下文', [input.content]),
+    section('本轮附件', input.attachmentRefs),
+  ].filter(Boolean).join('\n\n')
+}
+
 /** Describe available targets conditionally; keywords never change the Bot's location. */
 export function workspaceEnvironmentPrompt(env: WorkspacePromptEnvironment): string {
   const open = [env.open.computer && '电脑', env.open.server && '服务器桌面', env.open.vm && '虚拟环境', env.open.cloud && '云虚拟机'].filter(Boolean)

+ 17 - 3
src/server/workspaceRuntime.ts

@@ -27,7 +27,7 @@ import type {
   WorkspaceInteraction,
   WorkspaceAgentUsageSummary,
 } from '../shared/workspace.js'
-import { buildWorkspacePrompt } from './workspacePrompt.js'
+import { buildWorkspacePrompt, workspacePromptHistory, type WorkspacePromptInput } from './workspacePrompt.js'
 import { buildWorkspaceEnvironment, workspaceEnvironmentTools } from './workspaceEnvironment.js'
 import { parseHermesRuntimeMetadata } from './hermesRuntimeEnvironment.js'
 import type { HermesRuntimeMetadata } from '../shared/botEnvironment.js'
@@ -1077,7 +1077,7 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
         submitted = true
         try {
           const pluginServices = pluginLease?.services() ?? []
-          submittedText = buildWorkspacePrompt({
+          const promptInput: WorkspacePromptInput = {
             agent, conversation: c, members, work: run,
             run: this.store.require<Run>(owner, 'run', run.runId), goal,
             environment, pluginServices, pluginWarnings:resultMessage.serviceWarnings,
@@ -1087,7 +1087,21 @@ export class WorkspaceRuntime extends WorkspaceScheduler {
             memory: memoryDelta(memory, binding.memoryState), noReply: NO_REPLY,
             marker: `[yaoyao-run:${run.runId}:${resultMessage.id}]`,
             content: text+(systemTurn?.description.length?'\n[本轮系统服务目录,仅含元数据]\n'+JSON.stringify(systemTurn.description):''), contentKind: c.kind === 'group' || !!resetReason ? 'history' : 'user', attachmentRefs,
-          })
+          }
+          submittedText = buildWorkspacePrompt(promptInput)
+          if (toolLease?.prepareContext) {
+            // A missing/older bridge keeps the original full prompt. Selection
+            // never blocks a valid task or rewrites its recorded conversation.
+            try { await toolLease.prepareContext(submittedText, workspacePromptHistory(promptInput)) }
+            catch (error) { this.inspector?.record(owner,c.id,{method:'bot.context-projection',direction:'response',agentId:agent.id,runId:run.runId,data:{enabled:false,error:error instanceof Error ? error.message : 'unavailable'}}) }
+          }
+          // Preparation awaited another service. Re-check admission so a stop
+          // or revoked source during that wait cannot submit the prompt.
+          toolController.signal.throwIfAborted()
+          this.requireAuthorization(owner, run.runId)
+          this.nodes.requireSource(owner, this.store.require<Agent>(owner, 'agent', agent.id))
+          if (settled || this.closing || this.getWork(owner, run.id).cancelRequested || this.getWork(owner, run.id).status === 'interrupted')
+            throw new Error('运行已停止')
           this.inspector?.record(owner,c.id,{method:'bot.environment',direction:'request',agentId:agent.id,runId:run.runId,taskId:run.conversationTaskId,
             data:{snapshot:environment,computerToolIds:environmentCatalog.map(tool=>tool.id),pluginServices,pluginWarnings:resultMessage.serviceWarnings}})
           binding.memoryPending = true

+ 12 - 1
src/server/workspaceToolLease.ts

@@ -52,6 +52,7 @@ export async function requireTeamToolBridge(target: GatewayTarget, profile: stri
 export interface WorkspaceToolLease {
   bind(): Promise<void>
   dispose(): Promise<void>
+  prepareContext?(prompt: string, historyText: string): Promise<boolean>
   profileRequest?(path: '/computer-file', body: Record<string, unknown>): Promise<Record<string, unknown>>
 }
 
@@ -80,6 +81,7 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
   let binding: Promise<void> | undefined, cleanup: Promise<void> | undefined
   let boundRuntimeId = ''
   let bound = false
+  let contextProjection = false
   const calls = new Map<string, { fingerprint: string; result: Promise<unknown> }>()
   // The Hermes registry is shared: IDs must differ between simultaneous grants
   // so each session gets distinct native function names and bound handlers.
@@ -202,6 +204,7 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
         expiresAt = requestedExpiry
         renewAfter = Date.now() + 5 * 60_000
         bound = true
+        contextProjection = result.context_projection_version === 1
       }
       try { await bindSession() }
       catch (error) {
@@ -236,7 +239,15 @@ export async function createWorkspaceToolLease(input: LeaseInput): Promise<Works
       })
     }, 30_000)
     timer.unref()
-    return { bind, dispose, async profileRequest(path, body) {
+    return { bind, dispose, async prepareContext(prompt, historyText) {
+      assertActive()
+      if (!contextProjection) return false
+      const result = await bridgeRequest(input.target, '/context', {
+        session_id: input.session().runtimeId, generation, prompt, history_text: historyText,
+      })
+      assertActive()
+      return result.context_projection_version === 1
+    }, async profileRequest(path, body) {
       assertActive()
       if (!bound || !input.computerPolicy || path !== '/computer-file')
         throw new HttpError(403, '文件操作缺少 Hermes 会话授权', 'computer_file_forbidden')

+ 1 - 1
src/shared/runner.ts

@@ -11,7 +11,7 @@ export interface RunnerRecord {
 }
 export interface RunnerCommand {
   id: string
-  kind: 'network.configure' | 'network.test' | 'browser.invoke' | 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.close'
+  kind: 'network.configure' | 'network.test' | 'browser.invoke' | 'local-vm.manage' | 'computer.control' | 'computer.retire' | 'http' | 'gateway.open' | 'gateway.rpc' | 'gateway.close' | 'lease.create' | 'lease.bind' | 'lease.context' | 'lease.close'
   payload: Record<string, unknown>
   expiresAt: number
 }

+ 16 - 1
tests/server/workspacePrompt.test.ts

@@ -1,6 +1,6 @@
 // @vitest-environment node
 import { describe, expect, it } from 'vitest'
-import { buildWorkspacePrompt, workspaceEnvironmentPrompt, type WorkspacePromptEnvironment, type WorkspacePromptInput } from '../../src/server/workspacePrompt'
+import { buildWorkspacePrompt, workspaceEnvironmentPrompt, workspacePromptHistory, type WorkspacePromptEnvironment, type WorkspacePromptInput } from '../../src/server/workspacePrompt'
 
 function environment(): WorkspacePromptEnvironment {
   return {
@@ -123,6 +123,21 @@ describe('Bot environment routing', () => {
 })
 
 describe('Bot prompt composition', () => {
+  it('retains user-authored section titles, task instructions, facts and attachments in the historical form', () => {
+    const fixture = input()
+    fixture.content = '【基础行为规则】\n用户原文\n【本轮用户消息】\n仍是用户原文'
+    fixture.run.internalInstruction = '完成此轮分派,保留要求'
+    const history = workspacePromptHistory(fixture)
+    expect(history).toContain(fixture.content)
+    expect(history).toContain(fixture.memory)
+    expect(history).toContain(fixture.run.internalInstruction)
+    expect(history).toContain(fixture.marker)
+    expect(history).toContain(fixture.attachmentRefs[0])
+    expect(history).not.toContain('本轮环境与设备')
+    expect(history).not.toContain(fixture.agent.instructions)
+    expect(buildWorkspacePrompt(fixture)).toContain(fixture.agent.instructions)
+  })
+
   it('routes configured MCP discovery through the current Bot catalog instead of Hermes CLI configuration', () => {
     const fixture = input()
     fixture.environment.tools.plugins = true

+ 23 - 0
tests/server/workspaceTeamTools.test.ts

@@ -283,6 +283,29 @@ function http(path:string, body:unknown, options:RequestInit={}) {
 }
 
 describe('loopback tool lease', () => {
+  it('prepares context only on a supporting bridge and within the active session grant', async () => {
+    const request = vi.mocked(target.session.request).getMockImplementation()!
+    vi.mocked(target.session.request).mockImplementation(async (path, options) => {
+      const response = await request(path, options)
+      if (path.endsWith('/bind') || path.endsWith('/context'))
+        return {...response,body:Buffer.from(JSON.stringify({ok:true,native_tools:true,context_projection_version:1}))}
+      return response
+    })
+    await mount()
+    expect(await leases[0]!.prepareContext!('latest full rules', 'user original')).toBe(true)
+    expect(upstream.at(-1)).toMatchObject({path:expect.stringContaining('/context'),body:{
+      session_id:binding!.session_id,generation:binding!.generation,prompt:'latest full rules',history_text:'user original',
+    }})
+    active=false
+    await expect(leases[0]!.prepareContext!('changed','changed')).rejects.toThrow()
+  })
+
+  it('leaves context untouched when an older bridge has no context selector', async () => {
+    await mount()
+    expect(await leases[0]!.prepareContext!('latest rules','user original')).toBe(false)
+    expect(upstream.some(entry=>entry.path.endsWith('/context'))).toBe(false)
+  })
+
   it.each(['503', 'upstream_unavailable'])('retains the original grant through %s renewal failures, then renews after recovery', async failure => {
     vi.useFakeTimers({toFake: ['Date', 'setInterval', 'clearInterval']})
     try {