bridge_runtime.py 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198
  1. """Process-private, session-scoped tool capabilities for Hermes v0.21.0.
  2. Only this canonical module owns bindings. Nothing is written into prompts,
  3. global MCP settings, the Hermes environment or persistent session metadata.
  4. """
  5. from __future__ import annotations
  6. import base64
  7. import hmac
  8. import hashlib
  9. import json
  10. import math
  11. import mimetypes
  12. import os
  13. import platform
  14. import re
  15. import sys
  16. import threading
  17. import time
  18. import urllib.error
  19. import urllib.parse
  20. import urllib.request
  21. import uuid
  22. from contextvars import ContextVar
  23. from contextlib import contextmanager
  24. from dataclasses import dataclass, field
  25. from pathlib import Path
  26. from typing import Any
  27. VERSION = 1
  28. PLUGIN_VERSION = "1.3.1"
  29. def _plugin_fingerprint():
  30. root = Path(__file__).parent
  31. digest = hashlib.sha256()
  32. for path in sorted(p for p in root.rglob('*') if p.is_file() and '__pycache__' not in p.parts
  33. and 'tests' not in p.relative_to(root).parts and (p.suffix == '.py' or p.name in ('plugin.yaml', 'manifest.json'))):
  34. digest.update(path.relative_to(root).as_posix().encode() + b'\0' + path.read_bytes() + b'\0')
  35. return digest.hexdigest()
  36. # Freeze at module load: files copied later must not masquerade as loaded code.
  37. PLUGIN_FINGERPRINT = _plugin_fingerprint()
  38. MAX_LEASE_MS = 30 * 60 * 1000
  39. MAX_RESPONSE_BYTES = 64 * 1024 * 1024
  40. registered = False
  41. _lock = threading.RLock()
  42. _bindings: dict[str, "Binding"] = {}
  43. # Remember revoked generations while the live gateway object exists so an old
  44. # HTTP bind arriving after unbind cannot reinstate an obsolete capability.
  45. _generations: dict[str, tuple[dict, set[str]]] = {}
  46. _contexts: dict[Path, Any] = {}
  47. _children: dict[str, tuple["Binding", Any]] = {}
  48. _skill_review_scope: ContextVar[Any] = ContextVar("yaoyao_skill_review", default=None)
  49. _SKILL_REVIEW_TOOLS = frozenset({"skills_list", "skill_view", "skill_manage"})
  50. class BridgeError(Exception):
  51. def __init__(self, message: str, status: int = 403, code: str = "forbidden"):
  52. super().__init__(message)
  53. self.status = status
  54. self.code = code
  55. @dataclass(repr=False)
  56. class Binding:
  57. session_id: str
  58. stored_session_id: str
  59. profile: str
  60. profile_home: Path
  61. generation: str
  62. bridge_url: str
  63. token: str
  64. expires_at: float
  65. owner: tuple[str, str]
  66. session: dict
  67. agent: Any
  68. toolset: str = ""
  69. registrations: list = field(default_factory=list)
  70. native_names: set = field(default_factory=set)
  71. catalog_fingerprint: str = ""
  72. sampling: dict = field(default_factory=dict)
  73. computer_policy: dict | None = None
  74. workspace_memory: bool = False
  75. profile_memory_tools: set = field(default_factory=set)
  76. transfer_namespaces: set = field(default_factory=set)
  77. def _cleanup_file_transfers(namespaces):
  78. import subprocess
  79. for namespace in namespaces:
  80. try:
  81. subprocess.run([sys.executable, str(Path(__file__).with_name('file_transfer.py')), namespace],
  82. input=json.dumps({'op': 'transfer-cleanup'}), text=True, capture_output=True, timeout=5)
  83. except Exception:
  84. pass
  85. def register_context(ctx):
  86. from hermes_constants import get_hermes_home
  87. _contexts[Path(get_hermes_home()).resolve()] = ctx
  88. if callable(getattr(ctx, "register_hook", None)):
  89. ctx.register_hook("subagent_start", inherit_child)
  90. ctx.register_hook("subagent_stop", release_child)
  91. ctx.register_hook("pre_tool_call", refresh_catalog)
  92. ctx.register_hook("pre_tool_call", computer_directive)
  93. def _refresh_agent_tools(agent, binding):
  94. # Dashboard /bind runs outside the session's Profile context. Hermes keys
  95. # plugin registries and tool-definition caches by the active home; without
  96. # this scope a named Profile silently receives the default Profile catalog.
  97. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  98. context = set_hermes_home_override(binding.profile_home)
  99. try:
  100. _refresh_profile_agent_tools(agent, binding)
  101. finally:
  102. reset_hermes_home_override(context)
  103. def _hermes_cron_tool(tool_name):
  104. name = str(tool_name or "")
  105. return name in {"cron", "cronjob"} or name.startswith(("cron_", "cronjob_"))
  106. def _block_hermes_cron():
  107. return {"action": "block", "message": "此 Bot 的定时任务使用 workspace_list_routines、workspace_create_routine、workspace_update_routine 和 workspace_delete_routine。不要使用 Hermes cron。到点后在该 Bot 的单聊中执行。"}
  108. def _refresh_profile_agent_tools(agent, binding):
  109. # Bot sessions keep their own routines. Hermes cron stays available to ordinary profile chat.
  110. agent.disabled_toolsets = list(dict.fromkeys([*(getattr(agent, "disabled_toolsets", None) or []), "cron", "cronjob"]))
  111. if binding.workspace_memory:
  112. _isolate_profile_memory(agent, binding)
  113. _isolate_profile_context(agent, binding)
  114. # Hermes currently injects worker guidance when kanban_show is merely
  115. # available in a Profile. A Bot session is not a dispatched Kanban worker.
  116. # Keep the tools for explicit board requests, but remove the false mandate.
  117. if not os.getenv("HERMES_KANBAN_TASK", "").strip():
  118. agent._kanban_worker_guidance = ""
  119. from model_tools import get_tool_definitions, get_toolset_for_tool
  120. if not hasattr(agent, "_yaoyao_base_toolsets"):
  121. agent._yaoyao_base_toolsets = getattr(agent, "enabled_toolsets", None)
  122. base = agent._yaoyao_base_toolsets
  123. definitions = get_tool_definitions(enabled_toolsets=base,
  124. disabled_toolsets=getattr(agent, "disabled_toolsets", None), quiet_mode=True,
  125. skip_tool_search_assembly=True) or []
  126. # Expand wildcard scopes before adding a private lease. Otherwise an
  127. # `all` Agent's Tool Search could enumerate another Bot's dynamic schemas.
  128. enabled = sorted({toolset for tool in definitions
  129. if (toolset := get_toolset_for_tool(tool.get("function", {}).get("name", "")))
  130. and not toolset.startswith("yaoyao_turn_")})
  131. if binding.toolset and binding.toolset not in enabled:
  132. enabled.append(binding.toolset)
  133. agent.enabled_toolsets = enabled
  134. agent.tools = get_tool_definitions(enabled_toolsets=enabled,
  135. disabled_toolsets=getattr(agent, "disabled_toolsets", None), quiet_mode=True) or []
  136. agent.valid_tool_names = {tool["function"]["name"] for tool in agent.tools}
  137. if hasattr(agent, "_memory_manager"):
  138. from agent.memory_manager import inject_memory_provider_tools
  139. inject_memory_provider_tools(agent)
  140. invalidate = getattr(agent, "_invalidate_system_prompt", None)
  141. if callable(invalidate):
  142. invalidate()
  143. def _retire(binding):
  144. namespaces = tuple(binding.transfer_namespaces)
  145. binding.transfer_namespaces.clear()
  146. if namespaces:
  147. threading.Thread(target=_cleanup_file_transfers, args=(namespaces,), daemon=True).start()
  148. for child_id, (owner, _) in list(_children.items()):
  149. if owner is binding:
  150. _children.pop(child_id, None)
  151. for registration in reversed(binding.registrations):
  152. if registration is not None:
  153. registration.dispose()
  154. binding.registrations.clear()
  155. if binding.toolset:
  156. if hasattr(binding.agent, "_yaoyao_base_toolsets"):
  157. binding.agent.enabled_toolsets = binding.agent._yaoyao_base_toolsets
  158. del binding.agent._yaoyao_base_toolsets
  159. def inherit_child(parent_session_id=None, child_session_id=None, **kwargs):
  160. """Only the core lifecycle can introduce a child; model args cannot."""
  161. try:
  162. binding = _resolve(parent_session_id)
  163. inherited = _children.get(parent_session_id) or next((entry for entry in _children.values() if getattr(entry[1], "session_id", None) == parent_session_id), None)
  164. parent = inherited[1] if inherited else binding.agent
  165. child = next((item for item in getattr(parent, "_active_children", [])
  166. if getattr(item, "session_id", None) == child_session_id), None)
  167. if child is None or not child_session_id:
  168. return
  169. with _lock:
  170. _children[child_session_id] = (binding, child)
  171. _refresh_agent_tools(child, binding)
  172. except BridgeError:
  173. return
  174. def release_child(child_session_id=None, **kwargs):
  175. with _lock:
  176. for key, (_, child) in list(_children.items()):
  177. if key == child_session_id or getattr(child, "session_id", None) == child_session_id:
  178. _children.pop(key, None)
  179. def refresh_catalog(session_id=None, function_name=None, tool_name=None, **kwargs):
  180. if _skill_review_scope.get() is not None:
  181. return
  182. if (function_name or tool_name) not in {"tool_search", "tool_describe", "yaoyao_tools"}:
  183. return
  184. try:
  185. binding = _resolve(session_id)
  186. if binding.toolset:
  187. with _lock:
  188. _mount_native_tools(binding)
  189. except BridgeError:
  190. return
  191. def _mount_native_tools(binding, *, timeout=10):
  192. """Register session-scoped schemas in Hermes' own searchable catalog."""
  193. ctx = _contexts.get(binding.profile_home)
  194. if ctx is None:
  195. raise BridgeError("请安装新版工具桥并重新打开 Hermes 会话", 409, "tools_unavailable")
  196. catalog = _request(binding, "/tools/list", timeout=timeout)
  197. fingerprint = hashlib.sha256(json.dumps(catalog.get("tools", []), sort_keys=True).encode()).hexdigest()
  198. if fingerprint == binding.catalog_fingerprint:
  199. return
  200. for registration in reversed(binding.registrations):
  201. registration.dispose()
  202. binding.registrations.clear()
  203. binding.native_names.clear()
  204. binding.toolset = "yaoyao_turn_" + hashlib.sha256(binding.generation.encode()).hexdigest()[:16]
  205. for tool in catalog.get("tools", []):
  206. tool_id = _string(tool, "id")
  207. label = _string(tool, "name")
  208. name = "yaoyao_" + re.sub(r"[^a-zA-Z0-9_]", "_", label)[:30] + "_" + hashlib.sha256(tool_id.encode()).hexdigest()[:12]
  209. description = f"夭夭 Bot {label}。" + str(tool.get("description") or "")
  210. def handler(args, _tool_id=tool_id, **kwargs):
  211. try:
  212. if _resolve(kwargs.get("session_id", "")) is not binding:
  213. raise BridgeError("此工具属于其他轮次,授权已失效")
  214. return call_tool({"toolId": _tool_id, "arguments": args}, **kwargs)
  215. except BridgeError as exc:
  216. return json.dumps({"error": str(exc), "code": exc.code}, ensure_ascii=False)
  217. handle = ctx.register_tool(name=name, toolset=binding.toolset, description=description,
  218. schema={"name": name, "description": description, "parameters": tool.get("inputSchema") or {"type": "object", "properties": {}}}, handler=handler)
  219. if handle is None:
  220. raise BridgeError("Hermes 无法注册本轮工具", 503, "unsupported_runtime")
  221. binding.registrations.append(handle)
  222. binding.native_names.add(name)
  223. _refresh_agent_tools(binding.agent, binding)
  224. for owner, child in list(_children.values()):
  225. if owner is binding:
  226. _refresh_agent_tools(child, binding)
  227. binding.catalog_fingerprint = fingerprint
  228. async def sampling_request(owner, body):
  229. """Reuse Hermes' bounded native MCP sampling, scoped to this turn/profile."""
  230. binding = _resolve(_string(body, "session_id"))
  231. if binding.owner != owner or binding.generation != _string(body, "generation"):
  232. raise BridgeError("采样请求不属于当前轮次")
  233. name = _string(body, "server", 128)
  234. params = body.get("params")
  235. if not isinstance(params, dict):
  236. raise BridgeError("采样请求格式无效", 400)
  237. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  238. from tools.mcp_tool import SamplingHandler, _ensure_mcp_sdk
  239. _ensure_mcp_sdk()
  240. from mcp.types import CreateMessageRequestParams
  241. parsed = CreateMessageRequestParams.model_validate(params)
  242. context = set_hermes_home_override(binding.profile_home)
  243. try:
  244. handler = binding.sampling.setdefault(name, SamplingHandler(name, {"max_rpm": 10, "timeout": 30, "max_tokens_cap": 4096}))
  245. result = await handler(None, parsed)
  246. if _resolve(binding.session_id) is not binding:
  247. raise BridgeError("采样结果所属轮次已经结束")
  248. return result.model_dump(by_alias=True, exclude_none=True)
  249. finally:
  250. reset_hermes_home_override(context)
  251. def _web():
  252. # This module must execute inside the real Dashboard. Never create a new
  253. # Dashboard with a new auth token when a plugin is imported in another host.
  254. web = sys.modules.get("hermes_cli.web_server")
  255. if web is None or not callable(getattr(web, "_has_valid_session_token", None)):
  256. raise BridgeError("当前进程不是受支持的 Hermes Dashboard", 503, "unsupported_runtime")
  257. return web
  258. def _server():
  259. try:
  260. from tui_gateway import server
  261. except ImportError:
  262. raise BridgeError("Hermes 会话接口不可用", 503, "unsupported_runtime") from None
  263. if not isinstance(getattr(server, "_sessions", None), dict) or not hasattr(server, "_sessions_lock"):
  264. raise BridgeError("Hermes 会话接口不兼容,请修复工具桥或更新 Hermes", 503, "unsupported_runtime")
  265. if not callable(getattr(server, "_turn_isolation_enabled", None)):
  266. raise BridgeError("无法确认 Hermes 的运行模式", 503, "unsupported_runtime")
  267. try:
  268. isolated = server._turn_isolation_enabled()
  269. except Exception:
  270. raise BridgeError("无法确认 Hermes 的运行模式", 503, "unsupported_runtime") from None
  271. if isolated:
  272. raise BridgeError("工具桥仅支持 Hermes 非隔离运行模式", 503, "unsupported_mode")
  273. return server
  274. def request_identity(request) -> tuple[str, str]:
  275. """Use only the core HTTP gate's verified principal, never request JSON."""
  276. web = _web()
  277. if bool(getattr(web.app.state, "auth_required", False)):
  278. session = getattr(request.state, "session", None)
  279. user = getattr(session, "user_id", None)
  280. provider = getattr(session, "provider", None)
  281. if not isinstance(user, str) or not user or not isinstance(provider, str) or not provider:
  282. raise BridgeError("请先登录 Hermes Dashboard", 401, "unauthorized")
  283. if provider == "server-internal" or getattr(session, "expires_at", 0) <= time.time():
  284. raise BridgeError("Hermes 登录已过期", 401, "unauthorized")
  285. return provider, user
  286. if not web._has_valid_session_token(request):
  287. raise BridgeError("Hermes 本机令牌无效", 401, "unauthorized")
  288. return "local-session-token", "dashboard"
  289. def _transport_identity(transport) -> tuple[str, str]:
  290. from tui_gateway.ws import WSTransport
  291. if not isinstance(transport, WSTransport):
  292. raise BridgeError("仅支持已认证的 Dashboard WebSocket 会话")
  293. identity = getattr(transport, "auth_identity", None)
  294. web = _web()
  295. if bool(getattr(web.app.state, "auth_required", False)):
  296. if not isinstance(identity, dict):
  297. raise BridgeError("WebSocket 会话缺少可信身份")
  298. user, provider = identity.get("user_id"), identity.get("provider")
  299. if not isinstance(user, str) or not user or not isinstance(provider, str) or not provider or provider == "server-internal":
  300. raise BridgeError("WebSocket 会话身份不受支持")
  301. return provider, user
  302. # Identity None alone is not authority: stdio and unauthenticated fakes also
  303. # have no identity. Require the canonical token on the admitted WS object.
  304. ws = getattr(transport, "_ws", None)
  305. token = getattr(ws, "query_params", {}).get("token", "")
  306. expected = getattr(web, "_SESSION_TOKEN", "")
  307. if identity is not None or not isinstance(token, str) or not expected or not hmac.compare_digest(token, expected):
  308. raise BridgeError("WebSocket 会话与本机登录不匹配")
  309. return "local-session-token", "dashboard"
  310. def _runtime_environment(profile: str, definitions: list) -> dict:
  311. # Only directory facts: never execute tools, probe desktops, or expose config
  312. # credentials. Process metadata does not identify an SSH/container/UI backend.
  313. names = {tool.get("function", {}).get("name") for tool in definitions if isinstance(tool, dict)}
  314. names = {name for name in names if isinstance(name, str) and re.fullmatch(r"[A-Za-z0-9_.:-]{1,128}", name)}
  315. return {
  316. "version": 1, "profile": profile, "platform": sys.platform,
  317. "osRelease": platform.release(), "arch": platform.machine(),
  318. "nativeTools": {
  319. "terminal": sorted(names & {"terminal", "process", "process_manage", "execute_code", "code_execution"}),
  320. "files": sorted(names & {"read_file", "write_file", "patch", "search_files"}),
  321. "desktop": sorted(names & {"computer_use", "desktop_project"}),
  322. "browser": sorted(name for name in names if name.startswith("browser_")),
  323. },
  324. }
  325. def capabilities(profile: str = "default") -> dict:
  326. in_process = False
  327. try:
  328. _web()
  329. if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile):
  330. raise BridgeError("Profile 名称无效", 400, "invalid_profile")
  331. from hermes_cli.profiles import get_profile_dir, profile_exists
  332. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  333. if not profile_exists(profile):
  334. raise BridgeError("Hermes Profile 不存在", 404, "profile_missing")
  335. home = get_profile_dir(profile)
  336. # Dashboard APIs mount independently of the lazy tool-plugin loader.
  337. from hermes_cli.plugins import discover_plugins
  338. from hermes_cli.config import load_config
  339. from model_tools import get_tool_definitions
  340. context = set_hermes_home_override(home)
  341. try:
  342. server = _server()
  343. in_process = True
  344. discover_plugins()
  345. config = load_config()
  346. agent_config = config.get("agent") or {}
  347. definitions = get_tool_definitions(
  348. enabled_toolsets=server._load_enabled_toolsets(),
  349. disabled_toolsets=agent_config.get("disabled_toolsets") if isinstance(agent_config, dict) else None,
  350. quiet_mode=True, skip_tool_search_assembly=True,
  351. ) or []
  352. names = {t.get("function", {}).get("name") for t in definitions if isinstance(t, dict)}
  353. if not {"yaoyao_tools", "yaoyao_call"} <= names:
  354. raise BridgeError(f"Profile {profile} 尚未安装或启用夭夭工具桥,请检查该 Profile 的插件与工具集设置", 503, "profile_tools_unavailable")
  355. return {"version": VERSION, "ready": True, "in_process": True, "profile": profile, "native_tools": True,
  356. "computer_runtime_version": 2, "execution_policy_version": 1, "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT,
  357. "memory_isolation": True, "memory_isolation_transport": "bridge-bind-v1", "memory_extraction": True,
  358. "isolated_context_files": True, "skill_learning": True, "model_settings_version": 1, "file_transfer_version": 1,
  359. "runtime_environment": _runtime_environment(profile, definitions)}
  360. finally:
  361. reset_hermes_home_override(context)
  362. except Exception as exc:
  363. reason = str(exc) if isinstance(exc, BridgeError) else "无法确认 Hermes 插件接口兼容性"
  364. return {"version": VERSION, "ready": False, "in_process": in_process, "profile": profile, "reason": reason,
  365. "plugin_version": PLUGIN_VERSION, "plugin_fingerprint": PLUGIN_FINGERPRINT}
  366. def _string(body: dict, key: str, maximum: int = 512) -> str:
  367. value = body.get(key)
  368. if not isinstance(value, str) or not value or len(value) > maximum or any(ord(c) < 32 for c in value):
  369. raise BridgeError(f"参数 {key} 无效", 400, "invalid_request")
  370. return value
  371. def _bridge_url(value: str) -> str:
  372. try:
  373. parsed = urllib.parse.urlsplit(value)
  374. port = parsed.port
  375. if (
  376. parsed.scheme != "http" or parsed.hostname != "127.0.0.1"
  377. or not port or not 1 <= port <= 65535
  378. or parsed.username is not None or parsed.password is not None
  379. or parsed.path or parsed.query or parsed.fragment
  380. or value != f"http://127.0.0.1:{port}"
  381. ):
  382. raise ValueError()
  383. except ValueError:
  384. raise BridgeError("工具桥地址必须是本机 127.0.0.1 的 HTTP 端口", 400, "invalid_request") from None
  385. return value
  386. def _profile_home(session: dict, profile: str) -> Path:
  387. from hermes_cli.profiles import get_profile_dir, profile_matches_home
  388. from hermes_constants import get_process_hermes_home
  389. actual = Path(session.get("profile_home") or get_process_hermes_home()).resolve()
  390. if not profile_matches_home(profile, actual):
  391. raise BridgeError("Profile 与 Hermes 会话不匹配")
  392. return Path(get_profile_dir(profile)).resolve()
  393. def _check_session_mode(session: dict) -> None:
  394. # A session created before the process-wide setting changed can still own
  395. # a compute host. Its worker cannot share this process-private table.
  396. if session.get("_compute_host_active"):
  397. raise BridgeError("此 Hermes 会话仍使用隔离计算进程,请在非隔离模式下新建会话", 503, "unsupported_mode")
  398. def _expiry(body: dict) -> float:
  399. value = body.get("expires_at")
  400. now = time.time() * 1000
  401. if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or not now < value <= now + MAX_LEASE_MS + 5000:
  402. raise BridgeError("工具桥授权有效期无效", 400, "invalid_request")
  403. return value
  404. def _agent_has_tools(agent, home: Path) -> bool:
  405. required = {"yaoyao_tools", "yaoyao_call"}
  406. names = {
  407. t.get("function", {}).get("name")
  408. for t in (getattr(agent, "tools", None) or []) if isinstance(t, dict)
  409. }
  410. if required <= names:
  411. return True
  412. if not {"tool_call", "tool_describe"} <= names or not hasattr(agent, "enabled_toolsets") or not hasattr(agent, "disabled_toolsets"):
  413. return False
  414. # Current Hermes defers plugin schemas behind Tool Search. Check the same
  415. # scoped pre-assembly catalog its dispatcher uses, not process-global names
  416. # or text in the model-visible tool_search description.
  417. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  418. from model_tools import get_tool_definitions
  419. context = set_hermes_home_override(home)
  420. try:
  421. definitions = get_tool_definitions(
  422. enabled_toolsets=agent.enabled_toolsets,
  423. disabled_toolsets=agent.disabled_toolsets,
  424. quiet_mode=True, skip_tool_search_assembly=True,
  425. ) or []
  426. return required <= {t.get("function", {}).get("name") for t in definitions if isinstance(t, dict)}
  427. finally:
  428. reset_hermes_home_override(context)
  429. def _purge(server) -> None:
  430. now = time.time() * 1000
  431. for sid, binding in list(_bindings.items()):
  432. if binding.expires_at <= now or server._sessions.get(sid) is not binding.session:
  433. _bindings.pop(sid, None)
  434. _retire(binding)
  435. for sid, (session, _) in list(_generations.items()):
  436. if server._sessions.get(sid) is not session:
  437. _generations.pop(sid, None)
  438. for sid, (binding, _) in list(_children.items()):
  439. if _bindings.get(binding.session_id) is not binding:
  440. _children.pop(sid, None)
  441. @contextmanager
  442. def _bind_locks(server, deadline):
  443. # Lock contention is part of the binding deadline, not an unbounded wait
  444. # outside it. In particular a stalled earlier bind must not trap retries.
  445. acquired = []
  446. try:
  447. for lock in (server._sessions_lock, _lock):
  448. if not lock.acquire(timeout=max(0, deadline - time.monotonic())):
  449. raise BridgeError("Hermes 工具桥正在处理其他绑定,请稍后重试", 409, "binding_busy")
  450. acquired.append(lock)
  451. yield
  452. finally:
  453. for lock in reversed(acquired):
  454. lock.release()
  455. def bind(owner: tuple[str, str], body: dict, *, wait_seconds: float = 20) -> dict:
  456. workspace_memory = body.get("workspace_memory", False)
  457. if type(workspace_memory) is not bool:
  458. raise BridgeError("记忆隔离设置格式无效", 400, "invalid_memory_policy")
  459. policy = body.get("computer_policy")
  460. if policy is not None and (not isinstance(policy, dict) or set(policy) != {"mode", "hostAccess"}
  461. or policy.get("mode") not in {"isolated", "profile", "none", "virtual"}
  462. or type(policy.get("hostAccess")) is not bool):
  463. raise BridgeError("电脑会话权限格式无效", 400, "invalid_computer_policy")
  464. server = _server()
  465. sid = _string(body, "session_id")
  466. stored = _string(body, "stored_session_id")
  467. profile = _string(body, "profile", 128)
  468. generation = _string(body, "generation", 128)
  469. url = _bridge_url(_string(body, "bridge_url", 128))
  470. token = _string(body, "token", 512)
  471. if len(token) < 32:
  472. raise BridgeError("工具桥令牌长度不足", 400, "invalid_request")
  473. expiry = _expiry(body)
  474. deadline = time.monotonic() + wait_seconds
  475. build_requested = False
  476. while True:
  477. with _bind_locks(server, deadline):
  478. session = server._sessions.get(sid)
  479. if not isinstance(session, dict):
  480. raise BridgeError("Hermes 会话不存在", 404, "session_missing")
  481. _check_session_mode(session)
  482. if _transport_identity(session.get("transport")) != owner:
  483. raise BridgeError("当前登录不能绑定此会话")
  484. home = _profile_home(session, profile)
  485. if session.get("_finalized"):
  486. raise BridgeError("Hermes 会话已经结束", 409, "session_closed")
  487. agent = session.get("agent")
  488. if session.get("agent_error"):
  489. # Provider errors can contain credentials. Keep this response
  490. # bounded and let Hermes retain its detailed initialization log.
  491. raise BridgeError("Hermes Profile 初始化失败,工具桥尚未绑定", 409, "agent_initialization_failed")
  492. current = _bindings.get(sid)
  493. busy = bool(session.get("running")) and not (current and current.generation == generation)
  494. if agent is not None and not busy:
  495. break
  496. start_build = getattr(server, "_start_agent_build", None)
  497. needs_build = agent is None and not busy and not build_requested and callable(start_build)
  498. if needs_build:
  499. # Native, idempotent startup also covers resumed lazy sessions.
  500. # Never submit a prompt merely to trigger initialization: it would
  501. # run the first turn before memory/tools have been bound.
  502. build_requested = True
  503. start_build(sid, session)
  504. if time.monotonic() >= deadline:
  505. if busy:
  506. raise BridgeError("Hermes 会话正在运行,不能更换轮次授权", 409, "session_busy")
  507. raise BridgeError("Hermes 会话仍在初始化,请重试", 409, "initializing")
  508. time.sleep(0.1)
  509. with _bind_locks(server, deadline):
  510. _purge(server)
  511. expiry = _expiry(body)
  512. if server._sessions.get(sid) is not session or session.get("agent") is not agent:
  513. raise BridgeError("Hermes 会话已改变,请重试", 409, "session_changed")
  514. _check_session_mode(session)
  515. if _transport_identity(session.get("transport")) != owner or _profile_home(session, profile) != home:
  516. raise BridgeError("Hermes 会话身份或 Profile 已改变")
  517. current_keys = {str(session.get("session_key") or ""), str(getattr(agent, "session_id", "") or "")}
  518. if stored not in current_keys:
  519. raise BridgeError("持久会话 ID 与当前会话不匹配", 409, "session_changed")
  520. if not _agent_has_tools(agent, home):
  521. raise BridgeError("此会话尚未加载工具桥,请启用 yaoyao_bot_bridge 工具集并新建会话", 409, "tools_unavailable")
  522. current = _bindings.get(sid)
  523. if current and current.generation == generation:
  524. if current.session is not session or current.agent is not agent or current.owner != owner or current.profile != profile or current.bridge_url != url or current.computer_policy != policy or current.workspace_memory != workspace_memory or not hmac.compare_digest(current.token, token):
  525. raise BridgeError("不能更改现有轮次的工具授权", 409, "generation_conflict")
  526. current.expires_at = max(current.expires_at, expiry)
  527. current.stored_session_id = stored
  528. return {"ok": True, "generation": generation, "expires_at": current.expires_at,
  529. "native_tools": bool(current.toolset), "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": current.workspace_memory}
  530. previous = _generations.get(sid)
  531. seen = previous[1] if previous and previous[0] is session else set()
  532. if generation in seen:
  533. raise BridgeError("该轮次授权已撤销", 409, "generation_revoked")
  534. if session.get("running"):
  535. raise BridgeError("Hermes 会话正在运行,不能更换轮次授权", 409, "session_busy")
  536. if session.get("_yaoyao_workspace_memory") and not workspace_memory:
  537. raise BridgeError("此 Bot 会话必须继续使用独立记忆,请新建会话后更改模式", 409, "memory_policy_conflict")
  538. if len(seen) >= 4096:
  539. raise BridgeError("请恢复会话以更新工具桥授权记录", 409, "session_renewal_required")
  540. seen.add(generation)
  541. _generations[sid] = (session, seen)
  542. if current:
  543. _retire(current)
  544. binding = Binding(sid, stored, profile, home, generation, url, token, expiry, owner, session, agent)
  545. binding.computer_policy = dict(policy) if policy else None
  546. binding.workspace_memory = workspace_memory
  547. if workspace_memory:
  548. _isolate_profile_memory(agent, binding)
  549. session["_yaoyao_workspace_memory"] = True
  550. _isolate_profile_context(agent, binding)
  551. if policy:
  552. # Private runtime state survives unbind so a stale tool call cannot
  553. # fall back to the unrestricted native Profile after revocation.
  554. session["_yaoyao_computer_policy"] = dict(policy)
  555. _bindings[sid] = binding
  556. if body.get("native_tools") is True:
  557. try:
  558. _mount_native_tools(binding, timeout=max(0.1, min(10, deadline - time.monotonic())))
  559. except Exception:
  560. _bindings.pop(sid, None)
  561. _retire(binding)
  562. raise
  563. return {"ok": True, "generation": generation, "expires_at": expiry, "native_tools": bool(binding.toolset),
  564. "computer_runtime_version": 2, "execution_policy_version": 1, "workspace_memory": binding.workspace_memory}
  565. def unbind(owner: tuple[str, str], body: dict) -> dict:
  566. server = _server()
  567. sid, generation = _string(body, "session_id"), _string(body, "generation", 128)
  568. with server._sessions_lock, _lock:
  569. _purge(server)
  570. binding = _bindings.get(sid)
  571. if binding is None:
  572. return {"ok": True, "removed": False}
  573. if binding.owner != owner:
  574. raise BridgeError("当前登录不能撤销此会话的授权")
  575. if binding.generation != generation:
  576. return {"ok": True, "removed": False}
  577. _bindings.pop(sid, None)
  578. _retire(binding)
  579. return {"ok": True, "removed": True}
  580. def _resolve(session_id: str) -> Binding:
  581. # session_id is supplied by Hermes' tool dispatcher kwargs, never args.
  582. if not isinstance(session_id, str) or not session_id:
  583. raise BridgeError("工具调用缺少可信 Hermes 会话身份")
  584. server = _server()
  585. with server._sessions_lock, _lock:
  586. _purge(server)
  587. inherited = _children.get(session_id)
  588. if inherited is None:
  589. inherited = next((value for value in _children.values() if getattr(value[1], "session_id", None) == session_id), None)
  590. if inherited:
  591. binding, child = inherited
  592. if _bindings.get(binding.session_id) is not binding or getattr(child, "session_id", None) != session_id:
  593. raise BridgeError("子代理工具授权已失效")
  594. session_id = binding.session_id
  595. matches = [
  596. (sid, session) for sid, session in server._sessions.items()
  597. if session_id in {
  598. sid, str(session.get("session_key") or ""),
  599. str(getattr(session.get("agent"), "session_id", "") or ""),
  600. }
  601. ]
  602. if len(matches) != 1:
  603. raise BridgeError("无法确定工具调用所属的唯一会话")
  604. sid, session = matches[0]
  605. _check_session_mode(session)
  606. binding = _bindings.get(sid)
  607. if binding is None:
  608. raise BridgeError("本轮没有可用的夭夭工具授权,或授权已经结束")
  609. if binding.session is not session or session.get("agent") is not binding.agent:
  610. raise BridgeError("Hermes 会话已更换,旧工具授权失效")
  611. if _transport_identity(session.get("transport")) != binding.owner or _profile_home(session, binding.profile) != binding.profile_home:
  612. raise BridgeError("工具调用的身份或 Profile 已改变")
  613. if not session.get("running") or session.get("_finalized"):
  614. raise BridgeError("本轮已经停止,工具授权不可使用")
  615. return binding
  616. class _NoRedirect(urllib.request.HTTPRedirectHandler):
  617. def redirect_request(self, req, fp, code, msg, headers, newurl):
  618. raise BridgeError("工具桥拒绝重定向", 502, "bridge_redirect")
  619. def _request(binding: Binding, path: str, body: dict | None = None, *, timeout=600) -> dict:
  620. remaining = (binding.expires_at - time.time() * 1000) / 1000
  621. if remaining <= 0:
  622. raise BridgeError("本轮工具授权已过期")
  623. request = urllib.request.Request(
  624. binding.bridge_url + path,
  625. data=json.dumps({} if body is None else body, ensure_ascii=False).encode(),
  626. headers={"Authorization": "Bearer " + binding.token, "Content-Type": "application/json"},
  627. method="POST",
  628. )
  629. # Loopback never uses machine proxy settings; redirects must not leak token.
  630. opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect())
  631. try:
  632. with opener.open(request, timeout=min(remaining, timeout)) as response:
  633. raw = response.read(MAX_RESPONSE_BYTES + 1)
  634. if len(raw) > MAX_RESPONSE_BYTES:
  635. raise BridgeError("工具结果过大,请改用文件附件", 502, "result_too_large")
  636. result = json.loads(raw)
  637. if not isinstance(result, dict):
  638. raise ValueError()
  639. return result
  640. except urllib.error.HTTPError as exc:
  641. # Do not reflect upstream response/request objects: they can carry keys.
  642. raise BridgeError(f"工具桥拒绝请求(HTTP {exc.code})", 502, "bridge_rejected") from None
  643. except (urllib.error.URLError, TimeoutError, OSError):
  644. if path == "/tools/list":
  645. raise BridgeError("Hermes 未能读取夭夭的本轮工具目录,尚未提交聊天消息", 502, "bridge_catalog_unreachable") from None
  646. raise BridgeError("工具桥连接中断;操作可能已执行,请先检查结果,不要自动重试", 502, "bridge_unreachable") from None
  647. except (ValueError, UnicodeError):
  648. raise BridgeError("工具桥返回了无效结果", 502, "invalid_result") from None
  649. def _attachment(binding: Binding, data: bytes, mime: str) -> str:
  650. # Immutable UUID filenames, private directory and private files. Persist
  651. # outside conversations so file references survive Dashboard restarts.
  652. parent = binding.profile_home / "attachments"
  653. directory = parent / "yaoyao-bot-bridge"
  654. if parent.is_symlink() or directory.is_symlink():
  655. raise BridgeError("工具附件目录不能是符号链接", 502, "invalid_attachment_directory")
  656. parent.mkdir(mode=0o700, parents=True, exist_ok=True)
  657. extension = mimetypes.guess_extension(mime) or ".bin"
  658. if not re.fullmatch(r"\.[a-zA-Z0-9]{1,10}", extension):
  659. extension = ".bin"
  660. path = directory / (str(uuid.uuid4()) + extension)
  661. directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
  662. parent_fd = os.open(parent, directory_flags)
  663. try:
  664. try:
  665. os.mkdir("yaoyao-bot-bridge", mode=0o700, dir_fd=parent_fd)
  666. except FileExistsError:
  667. pass
  668. directory_fd = os.open("yaoyao-bot-bridge", directory_flags, dir_fd=parent_fd)
  669. try:
  670. os.fchmod(directory_fd, 0o700)
  671. fd = os.open(path.name, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600, dir_fd=directory_fd)
  672. with os.fdopen(fd, "wb") as stream:
  673. stream.write(data)
  674. finally:
  675. os.close(directory_fd)
  676. finally:
  677. os.close(parent_fd)
  678. return str(path)
  679. def _decode(value: Any) -> bytes:
  680. if not isinstance(value, str):
  681. raise BridgeError("工具附件缺少有效内容", 502, "invalid_result")
  682. try:
  683. return base64.b64decode(value, validate=True)
  684. except (ValueError, TypeError):
  685. raise BridgeError("工具附件编码无效", 502, "invalid_result") from None
  686. def normalize_result(result: dict, binding: Binding) -> str | dict:
  687. """Preserve MCP blocks; adapt pictures to Hermes' native vision envelope."""
  688. parts: list[dict] = []
  689. texts: list[str] = []
  690. has_image = False
  691. def text(value: str):
  692. texts.append(value)
  693. parts.append({"type": "text", "text": value})
  694. if result.get("isError"):
  695. text("工具执行失败:")
  696. blocks = result.get("content", [])
  697. if not isinstance(blocks, list):
  698. raise BridgeError("工具结果 content 无效", 502, "invalid_result")
  699. for block in blocks:
  700. if not isinstance(block, dict):
  701. text(json.dumps(block, ensure_ascii=False))
  702. continue
  703. kind = block.get("type")
  704. if kind == "text":
  705. text(str(block.get("text", "")))
  706. elif kind in {"image", "audio"}:
  707. mime = str(block.get("mimeType") or "application/octet-stream")
  708. data = _decode(block.get("data"))
  709. if kind == "image" and mime in {"image/png", "image/jpeg", "image/gif", "image/webp"}:
  710. has_image = True
  711. parts.append({"type": "image_url", "image_url": {"url": f"data:{mime};base64,{base64.b64encode(data).decode()}"}})
  712. texts.append(f"[图像 {mime},{len(data)} 字节]")
  713. else:
  714. path = _attachment(binding, data, mime)
  715. text(f"附件({mime}):{path}")
  716. elif kind == "resource" and isinstance(block.get("resource"), dict):
  717. resource = block["resource"]
  718. mime = str(resource.get("mimeType") or "application/octet-stream")
  719. if "blob" in resource:
  720. path = _attachment(binding, _decode(resource["blob"]), mime)
  721. text(f"附件({mime},来源 {resource.get('uri', '')}):{path}")
  722. elif "text" in resource:
  723. text(f"资源 {resource.get('uri', '')}\n{resource['text']}")
  724. else:
  725. text(json.dumps(block, ensure_ascii=False))
  726. elif kind == "resource_link":
  727. text(json.dumps(block, ensure_ascii=False))
  728. else:
  729. # Unknown future MCP content stays available as a JSON attachment,
  730. # rather than silently dropping bytes or sending invalid AI parts.
  731. path = _attachment(binding, json.dumps(block, ensure_ascii=False).encode(), "application/json")
  732. text(f"扩展工具结果:{path}")
  733. if "structuredContent" in result:
  734. text(json.dumps(result["structuredContent"], ensure_ascii=False))
  735. if not blocks and "structuredContent" not in result:
  736. text(json.dumps(result, ensure_ascii=False))
  737. summary = "\n".join(texts)
  738. return {"_multimodal": True, "content": parts, "text_summary": summary} if has_image else summary
  739. def list_tools(args: dict, **kwargs) -> str:
  740. try:
  741. if not isinstance(args, dict) or set(args) - {"service", "query", "offset", "limit"}:
  742. raise BridgeError("查询工具仅接受 service、query、offset 和 limit", 400, "invalid_request")
  743. for key in ("service", "query"):
  744. if key in args and (not isinstance(args[key], str) or len(args[key]) > 200):
  745. raise BridgeError("工具筛选必须是最多 200 字符的文本", 400, "invalid_request")
  746. for key, maximum in (("offset", 10000), ("limit", 50)):
  747. if key in args and (type(args[key]) is not int or not (0 if key == "offset" else 1) <= args[key] <= maximum):
  748. raise BridgeError("工具目录分页参数无效", 400, "invalid_request")
  749. binding = _resolve(kwargs.get("session_id", ""))
  750. result = _request(binding, "/tools/list")
  751. if _resolve(kwargs.get("session_id", "")) is not binding:
  752. raise BridgeError("本轮工具授权已经更换")
  753. if args:
  754. service = args.get("service", "").strip().casefold()
  755. terms = args.get("query", "").casefold().split()
  756. # Plugin descriptions start with the server-owned service label.
  757. # Keep the actual lease IDs and schemas; filtering grants no tools.
  758. tools = [tool for tool in result.get("tools", [])
  759. if (not service or service in str(tool.get("description", "")).partition(" · ")[0].casefold())
  760. and all(term in (str(tool.get("name", "")) + " " + str(tool.get("description", ""))).casefold() for term in terms)]
  761. offset, limit = args.get("offset", 0), args.get("limit", 20)
  762. result = {**result, "tools": tools[offset:offset + limit], "total": len(tools),
  763. "nextOffset": offset + limit if offset + limit < len(tools) else None}
  764. return json.dumps(result, ensure_ascii=False)
  765. except BridgeError as exc:
  766. return json.dumps({"error": str(exc), "code": exc.code}, ensure_ascii=False)
  767. def call_tool(args: dict, **kwargs) -> str | dict:
  768. try:
  769. if not isinstance(args, dict) or set(args) != {"toolId", "arguments"} or not isinstance(args.get("arguments"), dict):
  770. raise BridgeError("调用必须只包含 toolId 和 arguments", 400, "invalid_request")
  771. tool_id = _string(args, "toolId")
  772. binding = _resolve(kwargs.get("session_id", ""))
  773. # The model cannot pick identity, credentials or a replay identifier.
  774. # One UUID per invocation. No automatic retry of side-effecting tools.
  775. invocation = kwargs.get("tool_call_id")
  776. call_id = hashlib.sha256((str(kwargs.get("session_id")) + ":" + invocation).encode()).hexdigest() if isinstance(invocation, str) and invocation else str(uuid.uuid4())
  777. result = _request(binding, "/tools/call", {"toolId": tool_id, "arguments": args["arguments"], "callId": call_id})
  778. # A cancelled or replaced grant may finish in the background. Do not
  779. # release its result to a different turn after an HTTP round trip.
  780. if _resolve(kwargs.get("session_id", "")) is not binding:
  781. raise BridgeError("本轮工具授权已经更换")
  782. return normalize_result(result, binding)
  783. except BridgeError as exc:
  784. return json.dumps({"error": str(exc), "code": exc.code}, ensure_ascii=False)
  785. # Native Hermes remains the only owner of Profile configuration, skills and
  786. # authenticated integrations. The Runner never opens those files itself.
  787. def _isolate_profile_context(agent, binding):
  788. """Disable automatic host instruction files only for isolated Bot sessions.
  789. Apply on every bind/rebuild, including inherited children, before the model
  790. runs. Skills have their own loader; history and Profile config stay intact.
  791. """
  792. if (binding.computer_policy or {}).get("mode") not in {"isolated", "none", "virtual"}:
  793. return
  794. invalidate = getattr(agent, "_invalidate_system_prompt", None)
  795. if not all(hasattr(agent, name) for name in ("skip_context_files", "load_soul_identity")) or not callable(invalidate):
  796. raise BridgeError("当前 Hermes 不支持工具桥的会话上下文隔离", 409, "context_isolation_unavailable")
  797. agent.skip_context_files = True
  798. # This override loads SOUL even when skip_context_files is true.
  799. agent.load_soul_identity = False
  800. invalidate()
  801. def _isolate_profile_memory(agent, binding):
  802. """Apply the existing agent controls before any prompt, without editing Profile config.
  803. session.create is Hermes' strict public contract; memory isolation belongs
  804. to this authenticated session binding instead of invented RPC parameters.
  805. """
  806. required = ("_memory_store", "_memory_manager", "_memory_enabled", "_user_profile_enabled", "skip_background_review")
  807. if not all(hasattr(agent, name) for name in required) or not callable(getattr(agent, "_invalidate_system_prompt", None)):
  808. raise BridgeError("当前 Hermes 不支持工具桥的会话记忆隔离", 409, "memory_isolation_unavailable")
  809. manager = agent._memory_manager
  810. binding.profile_memory_tools.add("memory")
  811. if manager:
  812. binding.profile_memory_tools.update(manager.get_all_tool_names())
  813. agent._memory_store = None
  814. agent._memory_manager = None
  815. agent._memory_enabled = False
  816. agent._user_profile_enabled = False
  817. agent.skip_background_review = True
  818. agent.disabled_toolsets = list(dict.fromkeys([*(agent.disabled_toolsets or []), "memory"]))
  819. if manager:
  820. manager.shutdown_all()
  821. agent._invalidate_system_prompt()
  822. if agent is binding.agent:
  823. _enable_skill_review(agent, binding)
  824. @dataclass
  825. class _SkillReviewScope:
  826. parent: Any
  827. binding: Binding
  828. deadline: float
  829. agent: Any = None
  830. run: Any = None
  831. class _SkillReviewParent:
  832. """Keep Hermes' review lifecycle on the real parent; constrain its detached fork.
  833. Hermes registers the fork before its first model request. Intercept that
  834. per-instance assignment, without patching Hermes classes or global config.
  835. """
  836. def __init__(self, scope):
  837. object.__setattr__(self, "_scope", scope)
  838. def __getattr__(self, name):
  839. return getattr(self._scope.parent, name)
  840. def __setattr__(self, name, value):
  841. scope = self._scope
  842. if name == "_background_review_run" and value is not None:
  843. scope.run = value
  844. if name == "_background_review_agent" and value is not None:
  845. # Routed review models rebuild their prompt; same-model reviews may
  846. # rebuild after compaction. Both must retain the parent's isolation.
  847. value.skip_context_files = scope.parent.skip_context_files
  848. value.load_soul_identity = scope.parent.load_soul_identity
  849. value.skip_background_review = True
  850. value._memory_store = value._memory_manager = None
  851. value._memory_enabled = value._user_profile_enabled = False
  852. from model_tools import get_tool_definitions
  853. value.tools = [tool for tool in get_tool_definitions(enabled_toolsets=["skills"],
  854. disabled_toolsets=scope.parent.disabled_toolsets, quiet_mode=True,
  855. skip_tool_search_assembly=True) or [] if tool.get("function", {}).get("name") in _SKILL_REVIEW_TOOLS]
  856. value.valid_tool_names = {tool["function"]["name"] for tool in value.tools}
  857. value._invalidate_system_prompt()
  858. scope.agent = value
  859. setattr(scope.parent, name, value)
  860. def _enable_skill_review(agent, binding):
  861. """Use Hermes' native trigger/queue/review, while allowing skills only."""
  862. if (binding.computer_policy or {}).get("mode") in {"none", "virtual"}:
  863. return # Background review must not write skills into the unselected host.
  864. native = getattr(agent, "_spawn_background_review_now", None)
  865. required = ("skip_context_files", "load_soul_identity", "_background_review_agent", "_background_review_run")
  866. if (not all(hasattr(agent, name) for name in required) or not callable(native)
  867. or (not callable(getattr(native, "__func__", None)) and not hasattr(agent, "_yaoyao_skill_review_native"))):
  868. raise BridgeError("当前 Hermes 不支持工具桥的技能复盘", 409, "skill_review_unavailable")
  869. agent._yaoyao_skill_review_binding = binding
  870. if not hasattr(agent, "_yaoyao_skill_review_native"):
  871. agent._yaoyao_skill_review_native = native.__func__
  872. def skills_only(messages_snapshot, review_memory=False, review_skills=False, focus=None,
  873. task_cfg=None, _requeue_attempts=0, explicit=False):
  874. if not review_skills:
  875. return
  876. scope = _SkillReviewScope(agent, agent._yaoyao_skill_review_binding,
  877. time.monotonic() + MAX_LEASE_MS / 1000)
  878. token = _skill_review_scope.set(scope)
  879. try:
  880. # Hermes copies ContextVars into its review/tool threads. The
  881. # main turn never inherits this narrow post-turn permission.
  882. return agent._yaoyao_skill_review_native(_SkillReviewParent(scope), messages_snapshot,
  883. review_memory=False, review_skills=True, focus=focus, task_cfg=task_cfg,
  884. _requeue_attempts=_requeue_attempts, explicit=explicit)
  885. finally:
  886. _skill_review_scope.reset(token)
  887. agent._spawn_background_review_now = skills_only
  888. agent.skip_background_review = False
  889. def _skill_review_directive(scope, session_id, tool_name):
  890. from hermes_constants import get_hermes_home
  891. if (time.monotonic() >= scope.deadline or scope.agent is None or scope.run is None
  892. or getattr(scope.parent, "_background_review_agent", None) is not scope.agent
  893. or getattr(scope.parent, "_background_review_run", None) is not scope.run
  894. or scope.run.cancel_requested.is_set()
  895. or session_id != scope.agent.session_id
  896. or Path(get_hermes_home()).resolve() != scope.binding.profile_home.resolve()):
  897. return {"action": "block", "message": "本次技能复盘已结束或 Profile 不匹配。"}
  898. if tool_name not in _SKILL_REVIEW_TOOLS:
  899. return {"action": "block", "message": "技能复盘仅允许 skills_list、skill_view 和 skill_manage;不要调用记忆、文件、电脑或协作工具。"}
  900. return None
  901. def computer_directive(session_id=None, tool_name=None, args=None, **kwargs):
  902. scope = _skill_review_scope.get()
  903. if scope is not None:
  904. return _skill_review_directive(scope, session_id, tool_name)
  905. if _hermes_cron_tool(tool_name):
  906. try:
  907. _resolve(session_id)
  908. except BridgeError:
  909. pass
  910. else:
  911. return _block_hermes_cron()
  912. try:
  913. server = _server()
  914. except BridgeError:
  915. return None
  916. with server._sessions_lock, _lock:
  917. inherited = _children.get(session_id)
  918. sessions = [inherited[0].session] if inherited else [s for sid, s in server._sessions.items()
  919. if session_id in {sid, s.get("session_key"), getattr(s.get("agent"), "session_id", None)}]
  920. managed = next((s for s in sessions if s.get("_yaoyao_computer_policy") or s.get("_yaoyao_workspace_memory")), None)
  921. if managed is None:
  922. return None
  923. try:
  924. binding = _resolve(session_id)
  925. except BridgeError:
  926. return {"action": "block", "message": "电脑会话授权已经结束,请在当前 Bot 中发起新一轮。"}
  927. policy = binding.computer_policy
  928. if tool_name in {"kanban_show", "kanban_comment", "kanban_heartbeat"}:
  929. task_id = args.get("task_id") if isinstance(args, dict) else None
  930. if not (isinstance(task_id, str) and task_id.strip()) and not os.getenv("HERMES_KANBAN_TASK", "").strip():
  931. return {"action": "block", "message": "当前 Bot 对话没有 Hermes 看板 task_id;跳过看板步骤,继续用户原始任务。仅当用户需要看板操作时查询真实任务 ID,不要重试相同缺参调用,也不要用夭夭 run/task ID 代替。"}
  932. if binding.workspace_memory and tool_name in binding.profile_memory_tools:
  933. return {"action": "block", "message": "此 Bot 使用独立记忆,请使用本轮授权的 workspace 记忆工具。"}
  934. if not policy:
  935. return None if binding.workspace_memory else {"action": "block", "message": "当前轮次缺少电脑权限声明。"}
  936. if policy["mode"] in {"none", "virtual"}:
  937. # Unknown native tools are denied too. A newly installed plugin must not
  938. # regain access to Profile credentials or launch unrestricted children.
  939. dynamic = tool_name in binding.native_names
  940. if dynamic or tool_name in {"skills_list", "skill_view"}:
  941. return None
  942. return {"action": "block", "message": "系统未选择服务器本机,请使用本轮授权的虚拟环境或系统服务工具。"}
  943. if policy["mode"] == "profile" or policy["hostAccess"]:
  944. return None
  945. # Skill discovery, vision, and authenticated services retain the Profile's
  946. # existing tool permissions. Host commands/files/UI require Hermes approval.
  947. host_tools = {"terminal", "process", "process_manage", "read_file", "write_file", "patch", "search_files",
  948. "execute_code", "code_execution", "delegate_task", "computer_use", "desktop_project", "session_search"}
  949. host = tool_name in host_tools or str(tool_name).startswith(("browser_", "host_", "yaoyao_computer_copy_file_"))
  950. if not host:
  951. return None
  952. fingerprint = hashlib.sha256(json.dumps([tool_name, args], sort_keys=True, ensure_ascii=False).encode()).hexdigest()
  953. try:
  954. from agent.redact import redact_sensitive_text
  955. details = redact_sensitive_text(json.dumps(args or {}, ensure_ascii=False), force=True)[:4000]
  956. except Exception:
  957. return {"action": "block", "message": "无法安全显示本机操作内容,请使用虚拟机工具或重新发起请求。"}
  958. return {"action": "approve", "message": f"隔离 Bot 请求使用 Hermes 本机工具 {tool_name}。\n具体操作:{details}",
  959. "rule_key": "yaoyao-host:" + fingerprint}
  960. def computer_file(owner, body):
  961. binding = _resolve(_string(body, "session_id"))
  962. if binding.owner != owner or binding.generation != _string(body, "generation") or not binding.computer_policy:
  963. raise BridgeError("文件请求不属于当前电脑会话")
  964. if binding.computer_policy.get("mode") in {"none", "virtual"}:
  965. raise BridgeError("当前环境禁止访问 Hermes 宿主文件")
  966. action, raw = body.get("action"), _string(body, "path", 4096)
  967. transfer = body.get("transfer") if action == "transfer" else None
  968. if action == "transfer":
  969. action = body.get("direction")
  970. operations = {"read": {"transfer-read-open", "transfer-read", "transfer-status", "transfer-abort"},
  971. "write": {"transfer-write-open", "transfer-append", "transfer-finish", "transfer-status", "transfer-abort"}}
  972. if not isinstance(transfer, dict) or transfer.get("op") not in operations.get(action, set()):
  973. raise BridgeError("文件分块请求格式无效", 400)
  974. if action not in {"read", "write"} or "\0" in raw:
  975. raise BridgeError("文件请求格式无效", 400)
  976. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  977. context = set_hermes_home_override(binding.profile_home)
  978. try:
  979. cwd = Path(binding.session.get("cwd") or binding.profile_home)
  980. path = Path(raw).expanduser()
  981. path = (cwd / path).resolve() if not path.is_absolute() else path.resolve()
  982. # Respect Hermes' internal protected paths as well as regular-file and
  983. # size bounds; model-selected paths never become configuration inputs.
  984. from tools.file_tools import _check_sensitive_path, _check_protected_instruction_write, _check_approval_required_write
  985. from agent.file_safety import get_read_block_error
  986. error = get_read_block_error(str(path)) if action == "read" else _check_sensitive_path(str(path), str(binding.agent.session_id))
  987. if action == "write" and not error:
  988. error = (_check_protected_instruction_write([str(path)], str(binding.agent.session_id))
  989. or _check_approval_required_write([str(path)], str(binding.agent.session_id)))
  990. if error:
  991. raise BridgeError("该文件不在 Hermes 允许的文件访问范围内")
  992. if transfer is not None:
  993. import subprocess
  994. namespace = hashlib.sha256(json.dumps([binding.owner, binding.session_id, binding.generation, action, str(path)]).encode()).hexdigest()
  995. payload = dict(transfer)
  996. if payload.get("op") in {"transfer-read-open", "transfer-write-open"}:
  997. payload["path"] = str(path)
  998. with _lock:
  999. if _resolve(binding.session_id) is not binding:
  1000. raise BridgeError("文件所属轮次已经结束")
  1001. binding.transfer_namespaces.add(namespace)
  1002. try:
  1003. completed = subprocess.run([sys.executable, str(Path(__file__).with_name("file_transfer.py")), namespace],
  1004. input=json.dumps(payload), text=True, capture_output=True, cwd=str(cwd), timeout=30)
  1005. finally:
  1006. if _bindings.get(binding.session_id) is not binding:
  1007. _cleanup_file_transfers((namespace,))
  1008. if completed.returncode:
  1009. raise BridgeError("文件分块传输失败:" + completed.stderr.strip().split("\n")[-1][:300], 400)
  1010. if _resolve(binding.session_id) is not binding:
  1011. raise BridgeError("文件所属轮次已经结束")
  1012. result = json.loads(completed.stdout)
  1013. if "path" in result: result["path"] = raw
  1014. return {"ok": True, **result}
  1015. limit = 25 * 1024 * 1024
  1016. if action == "read":
  1017. import stat
  1018. fd = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
  1019. with os.fdopen(fd, "rb") as stream:
  1020. info = os.fstat(stream.fileno())
  1021. if not stat.S_ISREG(info.st_mode) or info.st_size > limit:
  1022. raise BridgeError("只支持不超过 25 MiB 的普通文件", 400)
  1023. data = stream.read(limit + 1)
  1024. if len(data) > limit:
  1025. raise BridgeError("文件超过 25 MiB", 413)
  1026. result = {"ok": True, "data": base64.b64encode(data).decode()}
  1027. else:
  1028. encoded = body.get("data")
  1029. if not isinstance(encoded, str) or len(encoded) > (limit + 2) // 3 * 4:
  1030. raise BridgeError("文件超过 25 MiB", 413)
  1031. try:
  1032. data = base64.b64decode(encoded, validate=True)
  1033. except ValueError:
  1034. raise BridgeError("文件编码无效", 400) from None
  1035. if len(data) > limit:
  1036. raise BridgeError("文件超过 25 MiB", 413)
  1037. _resolve(binding.session_id)
  1038. path.parent.mkdir(parents=True, exist_ok=True)
  1039. # Atomic replacement never follows an existing destination symlink.
  1040. import tempfile
  1041. fd, temporary = tempfile.mkstemp(prefix=".yaoyao-transfer-", dir=path.parent)
  1042. try:
  1043. with os.fdopen(fd, "wb") as stream:
  1044. stream.write(data)
  1045. _resolve(binding.session_id)
  1046. os.replace(temporary, path)
  1047. finally:
  1048. Path(temporary).unlink(missing_ok=True)
  1049. result = {"ok": True}
  1050. if _resolve(binding.session_id) is not binding:
  1051. raise BridgeError("文件所属轮次已经结束")
  1052. return result
  1053. finally:
  1054. reset_hermes_home_override(context)
  1055. async def extract_memory(profile, prompt):
  1056. import asyncio
  1057. if not isinstance(profile, str) or not re.fullmatch(r"[a-z0-9][a-z0-9_-]{0,63}", profile):
  1058. raise BridgeError("Profile 名称无效", 400)
  1059. if not isinstance(prompt, str) or not 0 < len(prompt) <= 65000:
  1060. raise BridgeError("记忆提炼输入无效", 400)
  1061. from hermes_cli.profiles import get_profile_dir, profile_exists
  1062. from hermes_constants import set_hermes_home_override, reset_hermes_home_override
  1063. if not profile_exists(profile):
  1064. raise BridgeError("Hermes Profile 不存在", 404)
  1065. context = set_hermes_home_override(get_profile_dir(profile))
  1066. try:
  1067. from agent.auxiliary_client import async_call_llm, extract_content_or_reasoning
  1068. from hermes_cli.config import load_config_readonly
  1069. from hermes_cli.runtime_provider import resolve_runtime_provider
  1070. model = load_config_readonly().get("model") or {}
  1071. if not isinstance(model, dict) or not model.get("default"):
  1072. raise BridgeError("Hermes Profile 尚未配置模型", 409)
  1073. runtime = resolve_runtime_provider(requested=model.get("provider"), target_model=model["default"],
  1074. explicit_base_url=model.get("base_url"), explicit_api_key=model.get("api_key"))
  1075. # An HTTP request has no agent turn context: never borrow the last
  1076. # multiplexed Profile's runtime from the auxiliary client's mirrors.
  1077. response = await asyncio.wait_for(async_call_llm(task="compression", messages=[
  1078. {"role": "system", "content": "仅从给定材料提炼记忆。不调用工具,不补充未经确认的事实。"},
  1079. {"role": "user", "content": prompt}], tools=[], max_tokens=4096, timeout=90,
  1080. main_runtime={**runtime, "model": model["default"]}), timeout=90)
  1081. return {"text": extract_content_or_reasoning(response)}
  1082. finally:
  1083. reset_hermes_home_override(context)