group_plugin_api.py 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636
  1. """Authenticated REST adapter for the authoritative Yaoyao Group store."""
  2. from __future__ import annotations
  3. import asyncio
  4. import base64
  5. import hashlib
  6. import importlib
  7. import inspect
  8. import logging
  9. import os
  10. import re
  11. import sqlite3
  12. import sys
  13. import threading
  14. from collections.abc import Callable
  15. from contextlib import asynccontextmanager
  16. from importlib.machinery import ModuleSpec
  17. from pathlib import Path
  18. from types import ModuleType
  19. from typing import Annotated, Any
  20. from uuid import UUID, uuid4
  21. from fastapi import APIRouter, File, Query, Request, UploadFile, WebSocket
  22. from fastapi import Path as APIPath
  23. from fastapi.exceptions import RequestValidationError
  24. from fastapi.responses import JSONResponse
  25. from fastapi.routing import APIRoute
  26. logger = logging.getLogger(__name__)
  27. _PLUGIN_NAME = "yaoyao"
  28. _agent_name_resolver: Callable[[str], str] = lambda _profile: ""
  29. _MAX_GROUP_UPLOAD_FILES = 8
  30. _MAX_GROUP_UPLOAD_FILE_BYTES = 25 * 1_024 * 1_024
  31. _MAX_GROUP_UPLOAD_REQUEST_BYTES = 50 * 1_024 * 1_024
  32. _GROUP_UPLOAD_CHUNK_BYTES = 1_024 * 1_024
  33. _DASHBOARD_DIR = Path(__file__).resolve().parent
  34. _LOCAL_PACKAGE = (
  35. "_hermes_yaoyao_group_"
  36. + hashlib.sha256(str(_DASHBOARD_DIR).encode("utf-8")).hexdigest()[:24]
  37. )
  38. def _log_runtime_failure(message: str, error: BaseException) -> None:
  39. """Log lifecycle context and type without attacker-controlled exception text."""
  40. logger.error("%s [errorType=%s]", message, type(error).__name__)
  41. def _plugin_runtime_enabled() -> bool:
  42. """Mirror the core HTTP plugin gate for the group WebSocket channel."""
  43. try:
  44. from hermes_cli import web_server
  45. from hermes_cli.plugins_cmd import _get_disabled_set, _get_enabled_set
  46. enabled_set = _get_enabled_set()
  47. disabled_set = _get_disabled_set()
  48. plugin = next(
  49. (
  50. candidate
  51. for candidate in web_server._get_dashboard_plugins()
  52. if candidate.get("name") == _PLUGIN_NAME
  53. ),
  54. None,
  55. )
  56. except Exception:
  57. return False
  58. if plugin is None:
  59. return False
  60. source = plugin.get("source")
  61. if source == "bundled":
  62. return _PLUGIN_NAME not in disabled_set
  63. if source == "user":
  64. return _PLUGIN_NAME in enabled_set and _PLUGIN_NAME not in disabled_set
  65. return False
  66. def _ensure_local_package() -> ModuleType:
  67. """Return the stable private package assigned to this resolved directory."""
  68. package = ModuleType(_LOCAL_PACKAGE)
  69. package.__package__ = _LOCAL_PACKAGE
  70. package.__path__ = [str(_DASHBOARD_DIR)]
  71. package_spec = ModuleSpec(_LOCAL_PACKAGE, loader=None, is_package=True)
  72. package_spec.submodule_search_locations = [str(_DASHBOARD_DIR)]
  73. package.__spec__ = package_spec
  74. shared = sys.modules.setdefault(_LOCAL_PACKAGE, package)
  75. paths = getattr(shared, "__path__", None)
  76. if (
  77. not isinstance(shared, ModuleType)
  78. or not paths
  79. or Path(paths[0]).resolve() != _DASHBOARD_DIR
  80. ):
  81. raise ImportError("Yaoyao Group private package identity is invalid")
  82. return shared
  83. _package = _ensure_local_package()
  84. _protocol = importlib.import_module(f"{_LOCAL_PACKAGE}.group_protocol")
  85. _store_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_store")
  86. _stream_module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_stream")
  87. _node_worker_module = importlib.import_module(
  88. f"{_LOCAL_PACKAGE}.group_node_worker"
  89. )
  90. _node_registry_module = importlib.import_module(
  91. f"{_LOCAL_PACKAGE}.group_node_registry"
  92. )
  93. PROTOCOL_VERSION = _protocol.PROTOCOL_VERSION
  94. EVENT_TYPES = _protocol.EVENT_TYPES
  95. MAX_MESSAGE_PAGE_SIZE = _protocol.MAX_MESSAGE_PAGE_SIZE
  96. limits_payload = _protocol.limits_payload
  97. normalize_interaction_id = _protocol.normalize_interaction_id
  98. AddAgentRequest = _protocol.AddAgentRequest
  99. ApprovalRequest = _protocol.ApprovalRequest
  100. ClarificationRequest = _protocol.ClarificationRequest
  101. CreateRoomRequest = _protocol.CreateRoomRequest
  102. RequestIDRequest = _protocol.RequestIDRequest
  103. SendMessageRequest = _protocol.SendMessageRequest
  104. UpdateAgentRequest = _protocol.UpdateAgentRequest
  105. UpdateRoomRequest = _protocol.UpdateRoomRequest
  106. UpdateTopicRequest = _protocol.UpdateTopicRequest
  107. MarkTopicReadRequest = _protocol.MarkTopicReadRequest
  108. NodeWorkerOpenRequest = _node_worker_module.NodeWorkerOpenRequest
  109. NodeWorkerPromptRequest = _node_worker_module.NodeWorkerPromptRequest
  110. NodeWorkerApprovalRequest = _node_worker_module.NodeWorkerApprovalRequest
  111. NodeWorkerClarificationRequest = _node_worker_module.NodeWorkerClarificationRequest
  112. NodeGatewayWorker = _node_worker_module.NodeGatewayWorker
  113. PairedNodeRegistry = _node_registry_module.PairedNodeRegistry
  114. GroupStore = _store_module.GroupStore
  115. GroupStoreError = _store_module.GroupStoreError
  116. GroupNotFoundError = _store_module.GroupNotFoundError
  117. GroupConflictError = _store_module.GroupConflictError
  118. IdempotencyConflict = _store_module.IdempotencyConflict
  119. class GroupAPIError(RuntimeError):
  120. """A stable public REST failure independent of internal exception text."""
  121. def __init__(
  122. self,
  123. code: str,
  124. message: str,
  125. status_code: int,
  126. details: dict[str, object] | None = None,
  127. ) -> None:
  128. super().__init__(message)
  129. self.code = code
  130. self.message = message
  131. self.status_code = status_code
  132. self.details = details or {}
  133. class RuntimeUnavailableError(GroupAPIError):
  134. def __init__(self) -> None:
  135. super().__init__(
  136. "runtime_unavailable",
  137. "Group runtime is unavailable",
  138. 503,
  139. )
  140. class GroupInvalidRequest(GroupAPIError):
  141. """A trusted Store or protocol input validator rejected the request."""
  142. def __init__(self, message: str) -> None:
  143. super().__init__("invalid_request", message, 400)
  144. class GroupStorageAPIError(GroupAPIError):
  145. """A raw SQLite failure hidden behind the public storage boundary."""
  146. def __init__(self) -> None:
  147. super().__init__("storage_error", "Group storage failed", 500)
  148. class GroupInternalAPIError(GroupAPIError):
  149. """An unexpected implementation failure safe for public serialization."""
  150. def __init__(self) -> None:
  151. super().__init__("internal_error", "Internal server error", 500)
  152. def _group_upload_root() -> Path:
  153. store_path = getattr(_store_instance(), "path", None)
  154. if not isinstance(store_path, Path):
  155. raise GroupStorageAPIError()
  156. return store_path.parent / "group-uploads"
  157. def _safe_upload_name(value: str | None) -> str:
  158. candidate = (value or "attachment").replace("\\", "/").rsplit("/", 1)[-1]
  159. cleaned = "".join(
  160. character
  161. for character in candidate
  162. if ord(character) >= 32 and ord(character) != 127
  163. ).strip()[:240]
  164. return cleaned or "attachment"
  165. def _safe_upload_extension(name: str) -> str:
  166. suffix = Path(name).suffix.lower()
  167. return suffix if re.fullmatch(r"\.[a-z0-9]{1,10}", suffix) else ""
  168. async def _persist_group_uploads(
  169. room_id: str,
  170. files: list[UploadFile],
  171. *,
  172. root: Path | None = None,
  173. ) -> list[dict[str, object]]:
  174. if not files:
  175. raise GroupInvalidRequest("At least one attachment is required")
  176. if len(files) > _MAX_GROUP_UPLOAD_FILES:
  177. raise GroupAPIError(
  178. "too_many_uploads",
  179. f"At most {_MAX_GROUP_UPLOAD_FILES} attachments are allowed",
  180. 413,
  181. )
  182. upload_root = (root or _group_upload_root()).resolve()
  183. room_root = upload_root / room_id
  184. created: list[Path] = []
  185. temporary: list[Path] = []
  186. total_bytes = 0
  187. result: list[dict[str, object]] = []
  188. try:
  189. room_root.mkdir(mode=0o700, parents=True, exist_ok=True)
  190. os.chmod(upload_root, 0o700)
  191. os.chmod(room_root, 0o700)
  192. for source in files:
  193. identifier = str(uuid4())
  194. name = _safe_upload_name(source.filename)
  195. destination = room_root / f"{identifier}{_safe_upload_extension(name)}"
  196. partial = room_root / f".{identifier}.part"
  197. temporary.append(partial)
  198. file_bytes = 0
  199. with partial.open("xb") as output:
  200. os.chmod(partial, 0o600)
  201. while True:
  202. chunk = await source.read(_GROUP_UPLOAD_CHUNK_BYTES)
  203. if not chunk:
  204. break
  205. file_bytes += len(chunk)
  206. total_bytes += len(chunk)
  207. if file_bytes > _MAX_GROUP_UPLOAD_FILE_BYTES:
  208. raise GroupAPIError(
  209. "upload_too_large",
  210. f"{name} exceeds 25 MiB",
  211. 413,
  212. )
  213. if total_bytes > _MAX_GROUP_UPLOAD_REQUEST_BYTES:
  214. raise GroupAPIError(
  215. "upload_request_too_large",
  216. "Combined attachments exceed 50 MiB",
  217. 413,
  218. )
  219. output.write(chunk)
  220. partial.replace(destination)
  221. temporary.remove(partial)
  222. os.chmod(destination, 0o600)
  223. created.append(destination)
  224. media_type = (source.content_type or "application/octet-stream").strip()
  225. result.append(
  226. {
  227. "id": identifier,
  228. "name": name,
  229. "path": str(destination),
  230. "mimeType": media_type or "application/octet-stream",
  231. "size": file_bytes,
  232. }
  233. )
  234. return result
  235. except GroupAPIError:
  236. for path in [*temporary, *created]:
  237. path.unlink(missing_ok=True)
  238. raise
  239. except (OSError, ValueError) as error:
  240. for path in [*temporary, *created]:
  241. path.unlink(missing_ok=True)
  242. _log_runtime_failure("Failed to persist a group attachment", error)
  243. raise GroupStorageAPIError() from error
  244. finally:
  245. for source in files:
  246. await source.close()
  247. def _error_response(error: GroupAPIError) -> JSONResponse:
  248. return JSONResponse(
  249. status_code=error.status_code,
  250. content={
  251. "detail": {
  252. "code": error.code,
  253. "message": error.message,
  254. "details": error.details,
  255. }
  256. },
  257. )
  258. def _validation_details(error: RequestValidationError) -> dict[str, object]:
  259. errors = [
  260. {
  261. "location": list(item.get("loc", ())),
  262. "message": item.get("msg", "Invalid value"),
  263. "type": item.get("type", "validation_error"),
  264. }
  265. for item in error.errors()
  266. ]
  267. return {"errors": errors}
  268. def _public_error(error: Exception) -> GroupAPIError:
  269. if isinstance(error, GroupAPIError):
  270. return error
  271. if isinstance(error, RequestValidationError):
  272. return GroupAPIError(
  273. "validation_error",
  274. "Request validation failed",
  275. 422,
  276. _validation_details(error),
  277. )
  278. if isinstance(error, IdempotencyConflict):
  279. return GroupAPIError("idempotency_conflict", str(error), 409)
  280. if isinstance(error, GroupNotFoundError):
  281. return GroupAPIError("not_found", str(error), 404)
  282. if isinstance(error, GroupConflictError):
  283. return GroupAPIError("conflict", str(error), 409)
  284. if isinstance(error, GroupStoreError):
  285. return GroupStorageAPIError()
  286. if isinstance(error, sqlite3.Error):
  287. return GroupStorageAPIError()
  288. return GroupInternalAPIError()
  289. class GroupAPIRoute(APIRoute):
  290. """Apply the plugin error envelope to validation and endpoint failures."""
  291. def get_route_handler(self) -> Callable[[Request], Any]:
  292. original = super().get_route_handler()
  293. async def wrapped(request: Request):
  294. try:
  295. return await original(request)
  296. except Exception as error:
  297. public = _public_error(error)
  298. return _error_response(public)
  299. return wrapped
  300. class _UnavailableRuntime:
  301. async def wake(self) -> None:
  302. """A missing orchestrator is harmless until Task 8 binds one."""
  303. async def create_room(self, **_: object) -> dict[str, object]:
  304. raise _package._yaoyao_group_runtime_unavailable_error()
  305. async def update_room(self, **_: object) -> dict[str, object]:
  306. raise _package._yaoyao_group_runtime_unavailable_error()
  307. async def archive_room(self, **_: object) -> dict[str, object]:
  308. raise _package._yaoyao_group_runtime_unavailable_error()
  309. async def restore_room(self, **_: object) -> dict[str, object]:
  310. raise _package._yaoyao_group_runtime_unavailable_error()
  311. async def archive_topic(self, **_: object) -> dict[str, object]:
  312. raise _package._yaoyao_group_runtime_unavailable_error()
  313. async def restore_topic(self, **_: object) -> dict[str, object]:
  314. raise _package._yaoyao_group_runtime_unavailable_error()
  315. async def add_agent(self, **_: object) -> dict[str, object]:
  316. raise _package._yaoyao_group_runtime_unavailable_error()
  317. async def update_agent(self, **_: object) -> dict[str, object]:
  318. raise _package._yaoyao_group_runtime_unavailable_error()
  319. async def delete_agent(self, **_: object) -> dict[str, object]:
  320. raise _package._yaoyao_group_runtime_unavailable_error()
  321. async def send_message(self, **_: object) -> dict[str, object]:
  322. raise _package._yaoyao_group_runtime_unavailable_error()
  323. async def interrupt_agent(self, **_: object) -> dict[str, object]:
  324. raise _package._yaoyao_group_runtime_unavailable_error()
  325. async def respond_approval(self, **_: object) -> dict[str, object]:
  326. raise _package._yaoyao_group_runtime_unavailable_error()
  327. async def respond_clarification(self, **_: object) -> dict[str, object]:
  328. raise _package._yaoyao_group_runtime_unavailable_error()
  329. def _default_orchestrator_factory(store: object) -> object:
  330. """Construct the runtime lazily so importing the plugin has no side effects."""
  331. module = importlib.import_module(f"{_LOCAL_PACKAGE}.group_orchestrator")
  332. return module.GroupOrchestrator(store, work_enabled=_plugin_runtime_enabled)
  333. class _RuntimeManager:
  334. """Own exactly one orchestrator for one nested Router lifespan generation."""
  335. def __init__(self) -> None:
  336. self._lock: asyncio.Lock | None = None
  337. self._leases = 0
  338. self._orchestrator: object | None = None
  339. self._accepting = False
  340. self._starting = False
  341. self._releasing = False
  342. self._generation = 0
  343. self._inflight = 0
  344. self._inflight_zero: asyncio.Event | None = None
  345. self._start_done: asyncio.Event | None = None
  346. self._release_done: asyncio.Event | None = None
  347. self._release_task: asyncio.Task[None] | None = None
  348. def _current_lock(self) -> asyncio.Lock:
  349. if self._lock is None:
  350. self._lock = asyncio.Lock()
  351. return self._lock
  352. async def acquire(self) -> None:
  353. lock = self._current_lock()
  354. while True:
  355. async with lock:
  356. if self._releasing:
  357. wait_for = self._release_done
  358. elif self._starting:
  359. wait_for = self._start_done
  360. elif self._leases:
  361. self._leases += 1
  362. return
  363. else:
  364. wait_for = None
  365. self._generation += 1
  366. generation = self._generation
  367. self._leases = 1
  368. self._starting = True
  369. self._accepting = False
  370. self._inflight = 0
  371. self._inflight_zero = asyncio.Event()
  372. self._inflight_zero.set()
  373. self._start_done = asyncio.Event()
  374. if _package._yaoyao_group_runtime_override:
  375. self._starting = False
  376. self._accepting = True
  377. self._start_done.set()
  378. return
  379. if wait_for is not None:
  380. await wait_for.wait()
  381. continue
  382. break
  383. orchestrator: object | None = None
  384. try:
  385. store = _store_instance()
  386. factory = _package._yaoyao_group_orchestrator_factory
  387. def adopt(created: object) -> None:
  388. nonlocal orchestrator
  389. orchestrator = created
  390. await self._run_owned_start_worker(factory, store, adopt=adopt)
  391. start = getattr(orchestrator, "start")
  392. await self._run_owned_start(start(), cancel_worker=True)
  393. except asyncio.CancelledError as start_cancellation:
  394. cleanup_cancellation: asyncio.CancelledError | None = None
  395. try:
  396. await self._cleanup_failed_start(orchestrator)
  397. except asyncio.CancelledError as error:
  398. cleanup_cancellation = error
  399. finally:
  400. async with lock:
  401. if self._generation == generation and self._starting:
  402. self._leases = 0
  403. self._starting = False
  404. self._reset_generation()
  405. if self._start_done is not None:
  406. self._start_done.set()
  407. self._start_done = None
  408. raise cleanup_cancellation or start_cancellation
  409. except BaseException as error: # noqa: BLE001 - isolate plugin startup
  410. cleanup_cancellation = None
  411. try:
  412. await self._cleanup_failed_start(orchestrator)
  413. except asyncio.CancelledError as cancelled:
  414. cleanup_cancellation = cancelled
  415. async with lock:
  416. _log_runtime_failure("YaoYao group runtime startup failed", error)
  417. if self._generation == generation and self._starting:
  418. if cleanup_cancellation is not None:
  419. self._leases = 0
  420. self._reset_generation()
  421. else:
  422. self._starting = False
  423. if self._start_done is not None:
  424. self._start_done.set()
  425. self._start_done = None
  426. if cleanup_cancellation is not None:
  427. raise cleanup_cancellation
  428. return
  429. async with lock:
  430. if self._generation != generation or not self._starting:
  431. await self._cleanup_failed_start(orchestrator)
  432. return
  433. self._orchestrator = orchestrator
  434. with _package._yaoyao_group_store_lock:
  435. if _package._yaoyao_group_store is store:
  436. _package._yaoyao_group_store_ready = True
  437. _publish_runtime(orchestrator)
  438. self._starting = False
  439. self._accepting = True
  440. if self._start_done is not None:
  441. self._start_done.set()
  442. self._start_done = None
  443. async def release(self) -> None:
  444. lock = self._current_lock()
  445. cancellation: asyncio.CancelledError | None = None
  446. while True:
  447. try:
  448. await lock.acquire()
  449. break
  450. except asyncio.CancelledError as error:
  451. if cancellation is None:
  452. cancellation = error
  453. if not self._discard_current_cancellation():
  454. raise
  455. release_task: asyncio.Task[None] | None = None
  456. try:
  457. if self._releasing:
  458. release_task = self._release_task
  459. elif self._leases:
  460. self._leases -= 1
  461. if self._leases == 0:
  462. self._releasing = True
  463. self._accepting = False
  464. self._release_done = asyncio.Event()
  465. generation = self._generation
  466. orchestrator = self._orchestrator
  467. release_done = self._release_done
  468. if not _package._yaoyao_group_runtime_override:
  469. _publish_runtime(_UnavailableRuntime())
  470. release_task = asyncio.create_task(
  471. self._release_generation(
  472. generation=generation,
  473. orchestrator=orchestrator,
  474. inflight_zero=self._inflight_zero,
  475. release_done=release_done,
  476. ),
  477. name=f"yaoyao-group-release-{generation}",
  478. )
  479. self._release_task = release_task
  480. finally:
  481. lock.release()
  482. if release_task is not None:
  483. try:
  484. await self._wait_for_owned_task(release_task)
  485. except BaseException:
  486. if cancellation is not None:
  487. raise cancellation
  488. raise
  489. if cancellation is not None:
  490. raise cancellation
  491. async def _release_generation(
  492. self,
  493. *,
  494. generation: int,
  495. orchestrator: object | None,
  496. inflight_zero: asyncio.Event | None,
  497. release_done: asyncio.Event,
  498. ) -> None:
  499. try:
  500. await self._wait_for_inflight_release(inflight_zero)
  501. if orchestrator is not None:
  502. await self._shutdown_owned_runtime(orchestrator, generation)
  503. except BaseException as error: # noqa: BLE001 - host shutdown must continue
  504. _log_runtime_failure("YaoYao group runtime shutdown failed", error)
  505. finally:
  506. await self._finish_owned_release(
  507. generation=generation,
  508. orchestrator=orchestrator,
  509. release_done=release_done,
  510. )
  511. @staticmethod
  512. async def _wait_for_owned_task(task: asyncio.Task[object]) -> object:
  513. """Return only after a manager-owned task, preserving caller cancellation."""
  514. cancellation: asyncio.CancelledError | None = None
  515. while not task.done():
  516. try:
  517. await asyncio.shield(task)
  518. except asyncio.CancelledError as error:
  519. if task.done():
  520. break
  521. if cancellation is None:
  522. cancellation = error
  523. try:
  524. result = task.result()
  525. except BaseException:
  526. if cancellation is not None:
  527. raise cancellation
  528. raise
  529. if cancellation is not None:
  530. raise cancellation
  531. return result
  532. @staticmethod
  533. def _discard_current_cancellation() -> bool:
  534. """Clear cancellation counts only for cleanup tasks that intentionally persist."""
  535. task = asyncio.current_task()
  536. cancelling = getattr(task, "cancelling", None)
  537. uncancel = getattr(task, "uncancel", None)
  538. if not callable(cancelling) or not callable(uncancel):
  539. return False
  540. had_cancellation = bool(cancelling())
  541. while cancelling():
  542. uncancel()
  543. return had_cancellation
  544. async def _wait_for_inflight_release(
  545. self, inflight_zero: asyncio.Event | None
  546. ) -> None:
  547. if inflight_zero is None:
  548. return
  549. while not inflight_zero.is_set():
  550. try:
  551. await inflight_zero.wait()
  552. except asyncio.CancelledError:
  553. if not self._discard_current_cancellation():
  554. raise
  555. async def _shutdown_owned_runtime(
  556. self, orchestrator: object, generation: int
  557. ) -> None:
  558. """Shield shutdown from release cancellation, including loop teardown."""
  559. shutdown_task = asyncio.create_task(
  560. self._run_owned_shutdown(orchestrator),
  561. name=f"yaoyao-group-runtime-shutdown-{generation}",
  562. )
  563. while not shutdown_task.done():
  564. try:
  565. await asyncio.shield(shutdown_task)
  566. except asyncio.CancelledError:
  567. self._discard_current_cancellation()
  568. shutdown_task.result()
  569. async def _run_owned_shutdown(self, orchestrator: object) -> None:
  570. while True:
  571. try:
  572. await getattr(orchestrator, "shutdown")()
  573. return
  574. except asyncio.CancelledError:
  575. if not self._discard_current_cancellation():
  576. raise
  577. async def _finish_owned_release(
  578. self,
  579. *,
  580. generation: int,
  581. orchestrator: object | None,
  582. release_done: asyncio.Event,
  583. ) -> None:
  584. lock = self._current_lock()
  585. while True:
  586. try:
  587. await lock.acquire()
  588. break
  589. except asyncio.CancelledError:
  590. if not self._discard_current_cancellation():
  591. raise
  592. try:
  593. if (
  594. self._generation == generation
  595. and self._orchestrator is orchestrator
  596. and self._releasing
  597. ):
  598. self._orchestrator = None
  599. self._releasing = False
  600. self._inflight = 0
  601. self._inflight_zero = None
  602. self._release_task = None
  603. self._reset_generation()
  604. if self._release_done is release_done:
  605. self._release_done = None
  606. finally:
  607. release_done.set()
  608. lock.release()
  609. async def dispatch(self, method: str, **kwargs: object) -> object:
  610. """Lease one published runtime through the complete request callback."""
  611. runtime = await self._begin_operation(require_runtime=True)
  612. async def invoke() -> object:
  613. callback = getattr(runtime, method)
  614. is_async = inspect.iscoroutinefunction(
  615. callback
  616. ) or inspect.iscoroutinefunction(getattr(callback, "__call__", None))
  617. if is_async:
  618. result = callback(**kwargs)
  619. else:
  620. result = await asyncio.to_thread(callback, **kwargs)
  621. if inspect.isawaitable(result):
  622. return await result
  623. return result
  624. return await self._run_owned_operation(
  625. invoke(), name=f"yaoyao-group-dispatch-{method}"
  626. )
  627. async def dispatch_store(
  628. self,
  629. method: str,
  630. *args: object,
  631. **kwargs: object,
  632. ) -> object:
  633. """Lease the complete blocking Store read through its worker return."""
  634. await self._begin_operation(require_runtime=False)
  635. return await self._run_owned_operation(
  636. asyncio.to_thread(_invoke_store, method, *args, **kwargs),
  637. name=f"yaoyao-group-store-{method}",
  638. )
  639. async def _begin_operation(self, *, require_runtime: bool) -> object | None:
  640. lock = self._current_lock()
  641. async with lock:
  642. override = bool(_package._yaoyao_group_runtime_override)
  643. if self._starting or self._releasing or not self._accepting:
  644. raise _package._yaoyao_group_runtime_unavailable_error()
  645. runtime = _package._yaoyao_group_runtime if override else self._orchestrator
  646. if require_runtime and (
  647. runtime is None or isinstance(runtime, _UnavailableRuntime)
  648. ):
  649. raise _package._yaoyao_group_runtime_unavailable_error()
  650. self._inflight += 1
  651. if self._inflight_zero is None:
  652. self._inflight_zero = asyncio.Event()
  653. self._inflight_zero.clear()
  654. return runtime
  655. async def _run_owned_operation(self, operation: object, *, name: str) -> object:
  656. if not inspect.isawaitable(operation):
  657. raise TypeError("leased operation must be awaitable")
  658. owned_task = asyncio.create_task(operation, name=name)
  659. def finished(done: asyncio.Task[object]) -> None:
  660. try:
  661. if not done.cancelled():
  662. done.exception()
  663. except asyncio.CancelledError:
  664. pass
  665. self._inflight -= 1
  666. if self._inflight == 0 and self._inflight_zero is not None:
  667. self._inflight_zero.set()
  668. owned_task.add_done_callback(finished)
  669. return await asyncio.shield(owned_task)
  670. async def stream_store(
  671. self, websocket: WebSocket, stream: Callable[..., object]
  672. ) -> object:
  673. """Lease a complete stream; closing invalidates its Store provider."""
  674. await self._begin_operation(require_runtime=False)
  675. generation = self._generation
  676. async def invoke() -> object:
  677. try:
  678. result = stream(
  679. websocket, lambda: self.store_for_generation(generation)
  680. )
  681. if inspect.isawaitable(result):
  682. return await result
  683. return result
  684. except RuntimeUnavailableError:
  685. await websocket.close(code=1013, reason="Group runtime is unavailable")
  686. return None
  687. return await self._run_owned_operation(
  688. invoke(), name=f"yaoyao-group-stream-{generation}"
  689. )
  690. def store_for_generation(self, generation: int) -> object:
  691. """Resolve Store only while a WebSocket still belongs to this generation."""
  692. if (
  693. generation != self._generation
  694. or self._starting
  695. or self._releasing
  696. or not self._accepting
  697. ):
  698. raise _package._yaoyao_group_runtime_unavailable_error()
  699. return _initialized_store()
  700. @staticmethod
  701. async def _cleanup_failed_start(orchestrator: object | None) -> None:
  702. if orchestrator is None:
  703. return
  704. try:
  705. await _RuntimeManager._run_owned_start(getattr(orchestrator, "shutdown")())
  706. except asyncio.CancelledError:
  707. raise
  708. except BaseException as error: # noqa: BLE001 - preserve startup isolation
  709. _log_runtime_failure("YaoYao group partial runtime cleanup failed", error)
  710. @staticmethod
  711. async def _run_owned_start(
  712. operation: object, *, cancel_worker: bool = False
  713. ) -> object:
  714. """Wait out caller cancellation before relinquishing startup ownership."""
  715. if not inspect.isawaitable(operation):
  716. raise TypeError("runtime start must be awaitable")
  717. worker = asyncio.create_task(operation, name="yaoyao-group-runtime-start")
  718. cancellation: asyncio.CancelledError | None = None
  719. while not worker.done():
  720. try:
  721. await asyncio.shield(worker)
  722. except asyncio.CancelledError as error:
  723. if cancellation is None:
  724. cancellation = error
  725. if cancel_worker:
  726. worker.cancel()
  727. try:
  728. result = worker.result()
  729. except BaseException:
  730. if cancellation is not None:
  731. raise cancellation
  732. raise
  733. if cancellation is not None:
  734. raise cancellation
  735. return result
  736. @staticmethod
  737. async def _run_owned_start_worker(
  738. callback: Callable[..., object],
  739. *args: object,
  740. adopt: Callable[[object], None],
  741. ) -> object:
  742. worker = asyncio.create_task(
  743. asyncio.to_thread(callback, *args),
  744. name="yaoyao-group-runtime-create",
  745. )
  746. cancellation: asyncio.CancelledError | None = None
  747. while not worker.done():
  748. try:
  749. await asyncio.shield(worker)
  750. except asyncio.CancelledError as error:
  751. if cancellation is None:
  752. cancellation = error
  753. try:
  754. result = worker.result()
  755. except BaseException:
  756. if cancellation is not None:
  757. raise cancellation
  758. raise
  759. adopt(result)
  760. if cancellation is not None:
  761. raise cancellation
  762. return result
  763. def _reset_generation(self) -> None:
  764. self._accepting = False
  765. self._starting = False
  766. if not _package._yaoyao_group_store_override:
  767. with _package._yaoyao_group_store_lock:
  768. _package._yaoyao_group_store = None
  769. _package._yaoyao_group_store_ready = False
  770. @asynccontextmanager
  771. async def _router_lifespan(_: object):
  772. manager = _package._yaoyao_group_runtime_manager
  773. await manager.acquire()
  774. try:
  775. yield
  776. finally:
  777. await manager.release()
  778. _bootstrap_lock = vars(_package).setdefault(
  779. "_yaoyao_group_bootstrap_lock", threading.RLock()
  780. )
  781. with _bootstrap_lock:
  782. if not hasattr(_package, "_yaoyao_group_store_lock"):
  783. _package._yaoyao_group_runtime_unavailable_error = RuntimeUnavailableError
  784. _package._yaoyao_group_store_lock = threading.Lock()
  785. _package._yaoyao_group_store = None
  786. _package._yaoyao_group_store_ready = False
  787. _package._yaoyao_group_store_override = False
  788. _package._yaoyao_group_runtime = _UnavailableRuntime()
  789. _package._yaoyao_group_runtime_override = False
  790. _package._yaoyao_group_orchestrator_factory = _default_orchestrator_factory
  791. _package._yaoyao_group_runtime_manager = _RuntimeManager()
  792. _package._yaoyao_node_worker_lock = threading.Lock()
  793. _package._yaoyao_node_worker = None
  794. _package._yaoyao_node_registry_lock = threading.Lock()
  795. _package._yaoyao_node_registry = None
  796. if not hasattr(_package, "_yaoyao_node_worker_lock"):
  797. _package._yaoyao_node_worker_lock = threading.Lock()
  798. _package._yaoyao_node_worker = None
  799. if not hasattr(_package, "_yaoyao_node_registry_lock"):
  800. _package._yaoyao_node_registry_lock = threading.Lock()
  801. _package._yaoyao_node_registry = None
  802. def set_store_for_testing(store: object | None) -> None:
  803. """Inject an isolated store; initialization remains lazy and off-loop."""
  804. with _package._yaoyao_group_store_lock:
  805. _package._yaoyao_group_store = store
  806. _package._yaoyao_group_store_ready = False
  807. _package._yaoyao_group_store_override = store is not None
  808. def set_agent_name_resolver(resolver: Callable[[str], str]) -> None:
  809. """Use the host-provided Hermes Bot display-name resolver."""
  810. global _agent_name_resolver
  811. _agent_name_resolver = resolver
  812. def set_node_worker_for_testing(worker: object | None) -> None:
  813. with _package._yaoyao_node_worker_lock:
  814. current = _package._yaoyao_node_worker
  815. if current is not None and current is not worker:
  816. shutdown = getattr(current, "shutdown", None)
  817. if callable(shutdown):
  818. shutdown()
  819. _package._yaoyao_node_worker = worker
  820. def _node_worker_instance() -> object:
  821. with _package._yaoyao_node_worker_lock:
  822. worker = _package._yaoyao_node_worker
  823. if worker is None:
  824. worker = NodeGatewayWorker()
  825. _package._yaoyao_node_worker = worker
  826. return worker
  827. async def _node_worker_call(method: str, *args: object, **kwargs: object) -> object:
  828. worker = _node_worker_instance()
  829. operation = getattr(worker, method, None)
  830. if not callable(operation):
  831. raise RuntimeUnavailableError()
  832. return await asyncio.to_thread(operation, *args, **kwargs)
  833. def set_node_registry_for_testing(registry: object | None) -> None:
  834. with _package._yaoyao_node_registry_lock:
  835. _package._yaoyao_node_registry = registry
  836. def _node_registry_instance() -> object:
  837. with _package._yaoyao_node_registry_lock:
  838. registry = _package._yaoyao_node_registry
  839. if registry is None:
  840. registry = PairedNodeRegistry.from_environment()
  841. _package._yaoyao_node_registry = registry
  842. return registry
  843. async def _node_registry_call(
  844. method: str, *args: object, **kwargs: object
  845. ) -> object:
  846. registry = _node_registry_instance()
  847. operation = getattr(registry, method, None)
  848. if not callable(operation):
  849. raise RuntimeUnavailableError()
  850. return await asyncio.to_thread(operation, *args, **kwargs)
  851. def set_runtime_facade(runtime: object | None) -> None:
  852. """Bind the narrow Task-8 runtime surface, or restore the safe default."""
  853. _package._yaoyao_group_runtime_override = runtime is not None
  854. _publish_runtime(_UnavailableRuntime() if runtime is None else runtime)
  855. manager = _package._yaoyao_group_runtime_manager
  856. if runtime is None:
  857. manager._accepting = False
  858. elif not manager._starting and not manager._releasing:
  859. manager._accepting = True
  860. def set_orchestrator_factory_for_testing(
  861. factory: Callable[[object], object] | None,
  862. ) -> None:
  863. """Inject construction without importing the real Gateway runtime."""
  864. _package._yaoyao_group_orchestrator_factory = (
  865. _default_orchestrator_factory if factory is None else factory
  866. )
  867. def _publish_runtime(runtime: object) -> None:
  868. _package._yaoyao_group_runtime = runtime
  869. def _store_instance() -> object:
  870. with _package._yaoyao_group_store_lock:
  871. if _package._yaoyao_group_store is None:
  872. _package._yaoyao_group_store = GroupStore.from_environment(
  873. agent_name_resolver=_agent_name_resolver
  874. )
  875. return _package._yaoyao_group_store
  876. def _initialized_store() -> object:
  877. with _package._yaoyao_group_store_lock:
  878. if _package._yaoyao_group_store is None:
  879. _package._yaoyao_group_store = GroupStore.from_environment(
  880. agent_name_resolver=_agent_name_resolver
  881. )
  882. store = _package._yaoyao_group_store
  883. if not _package._yaoyao_group_store_ready:
  884. store.initialize()
  885. _package._yaoyao_group_store_ready = True
  886. return store
  887. def _invoke_store(method: str, *args: object, **kwargs: object) -> object:
  888. """Run one trusted Store operation and classify only its input failures."""
  889. try:
  890. store = _initialized_store()
  891. except sqlite3.Error as error:
  892. raise GroupStorageAPIError() from error
  893. try:
  894. return getattr(store, method)(*args, **kwargs)
  895. except ValueError as error:
  896. raise GroupInvalidRequest(str(error)) from error
  897. except sqlite3.Error as error:
  898. raise GroupStorageAPIError() from error
  899. async def _store_call(method: str, *args: object, **kwargs: object) -> object:
  900. return await _package._yaoyao_group_runtime_manager.dispatch_store(
  901. method, *args, **kwargs
  902. )
  903. async def _runtime_call(method: str, **kwargs: object) -> object:
  904. return await _package._yaoyao_group_runtime_manager.dispatch(method, **kwargs)
  905. def _command(model: object, *, exclude_unset: bool = False) -> dict[str, object]:
  906. return model.model_dump(
  907. mode="json",
  908. by_alias=True,
  909. exclude_unset=exclude_unset,
  910. )
  911. def _create_room_command(request: CreateRoomRequest) -> dict[str, object]:
  912. command = _command(request)
  913. if "max_reply_rounds" not in request.model_fields_set:
  914. command.pop("maxReplyRounds", None)
  915. if "orchestration_mode" not in request.model_fields_set:
  916. command.pop("orchestrationMode", None)
  917. if "instructions" not in request.model_fields_set:
  918. command.pop("instructions", None)
  919. if "avatar" not in request.model_fields_set:
  920. command.pop("avatar", None)
  921. agents = command.get("agents")
  922. if isinstance(agents, list):
  923. for seed, item in zip(request.agents, agents):
  924. if (
  925. isinstance(item, dict)
  926. and "reply_without_mention" not in seed.model_fields_set
  927. ):
  928. item.pop("replyWithoutMention", None)
  929. if isinstance(item, dict) and "is_host" not in seed.model_fields_set:
  930. item.pop("isHost", None)
  931. if isinstance(item, dict) and "node_id" not in seed.model_fields_set:
  932. item.pop("nodeId", None)
  933. if isinstance(item, dict) and "node_label" not in seed.model_fields_set:
  934. item.pop("nodeLabel", None)
  935. if isinstance(item, dict):
  936. for field, alias in (
  937. ("model", "model"),
  938. ("provider", "provider"),
  939. ("reasoning_effort", "reasoningEffort"),
  940. ("fast_mode", "fastMode"),
  941. ):
  942. if field not in seed.model_fields_set:
  943. item.pop(alias, None)
  944. return command
  945. def _add_agent_command(request: AddAgentRequest) -> dict[str, object]:
  946. command = _command(request)
  947. if "reply_without_mention" not in request.model_fields_set:
  948. command.pop("replyWithoutMention", None)
  949. if "is_host" not in request.model_fields_set:
  950. command.pop("isHost", None)
  951. if "node_id" not in request.model_fields_set:
  952. command.pop("nodeId", None)
  953. if "node_label" not in request.model_fields_set:
  954. command.pop("nodeLabel", None)
  955. for field, alias in (
  956. ("model", "model"),
  957. ("provider", "provider"),
  958. ("reasoning_effort", "reasoningEffort"),
  959. ("fast_mode", "fastMode"),
  960. ):
  961. if field not in request.model_fields_set:
  962. command.pop(alias, None)
  963. return command
  964. def _validated_interaction_id(value: str) -> str:
  965. try:
  966. return normalize_interaction_id(value)
  967. except ValueError as error:
  968. raise GroupInvalidRequest(str(error)) from error
  969. async def capabilities() -> dict[str, object]:
  970. journal_epoch = await _store_call("journal_epoch")
  971. latest_cursor = await _store_call("latest_cursor")
  972. return {
  973. "protocolVersion": PROTOCOL_VERSION,
  974. "journalEpoch": journal_epoch,
  975. "latestCursor": latest_cursor,
  976. "limits": limits_payload(),
  977. "eventTypes": sorted(EVENT_TYPES),
  978. "features": [
  979. "hostFlow", "roomInstructions", "roomAvatar", "nodeWorker", "nodeRegistry"
  980. ],
  981. }
  982. async def list_rooms(
  983. limit: int = Query(default=50, ge=1, le=100),
  984. cursor: str | None = Query(default=None, min_length=1, max_length=4096),
  985. archived: bool = Query(default=False),
  986. ) -> dict[str, object]:
  987. page = await _store_call(
  988. "list_rooms", limit=limit, cursor=cursor, archived=archived
  989. )
  990. return {"items": page.items, "nextCursor": page.next_cursor}
  991. async def list_topics(
  992. room_id: UUID,
  993. limit: int = Query(default=50, ge=1, le=100),
  994. cursor: str | None = Query(default=None, min_length=1, max_length=4096),
  995. archived: bool = Query(default=False),
  996. ) -> dict[str, object]:
  997. page = await _store_call(
  998. "list_topics", str(room_id), limit=limit, cursor=cursor,
  999. archived=archived,
  1000. )
  1001. return {"items": page.items, "nextCursor": page.next_cursor}
  1002. async def list_pinned_topics(
  1003. limit: int = Query(default=100, ge=1, le=100),
  1004. ) -> dict[str, object]:
  1005. page = await _store_call("list_pinned_topics", limit=limit)
  1006. return {"items": page.items, "nextCursor": page.next_cursor}
  1007. async def update_topic(
  1008. room_id: UUID, topic_id: UUID, request: UpdateTopicRequest
  1009. ) -> dict[str, object]:
  1010. return await _runtime_call(
  1011. "update_topic",
  1012. room_id=str(room_id),
  1013. topic_id=str(topic_id),
  1014. command=_command(request, exclude_unset=True),
  1015. )
  1016. async def archive_topic(
  1017. room_id: UUID, topic_id: UUID, request: RequestIDRequest
  1018. ) -> dict[str, object]:
  1019. return await _runtime_call(
  1020. "archive_topic", room_id=str(room_id), topic_id=str(topic_id),
  1021. request_id=str(request.request_id),
  1022. )
  1023. async def restore_topic(
  1024. room_id: UUID, topic_id: UUID, request: RequestIDRequest
  1025. ) -> dict[str, object]:
  1026. return await _runtime_call(
  1027. "restore_topic", room_id=str(room_id), topic_id=str(topic_id),
  1028. request_id=str(request.request_id),
  1029. )
  1030. async def mark_topic_read(
  1031. room_id: UUID, topic_id: UUID, request: MarkTopicReadRequest
  1032. ) -> dict[str, object]:
  1033. return await _runtime_call(
  1034. "mark_topic_read",
  1035. room_id=str(room_id),
  1036. topic_id=str(topic_id),
  1037. command=_command(request, exclude_unset=True),
  1038. )
  1039. async def create_room(request: CreateRoomRequest) -> dict[str, object]:
  1040. return await _runtime_call("create_room", command=_create_room_command(request))
  1041. async def get_room(room_id: UUID) -> dict[str, object]:
  1042. return await _store_call("room_snapshot", str(room_id))
  1043. async def update_room(room_id: UUID, request: UpdateRoomRequest) -> dict[str, object]:
  1044. return await _runtime_call(
  1045. "update_room",
  1046. room_id=str(room_id),
  1047. command=_command(request, exclude_unset=True),
  1048. )
  1049. async def archive_room(room_id: UUID, request: RequestIDRequest) -> dict[str, object]:
  1050. return await _runtime_call(
  1051. "archive_room",
  1052. room_id=str(room_id),
  1053. request_id=str(request.request_id),
  1054. )
  1055. async def restore_room(room_id: UUID, request: RequestIDRequest) -> dict[str, object]:
  1056. return await _runtime_call(
  1057. "restore_room", room_id=str(room_id), request_id=str(request.request_id)
  1058. )
  1059. async def add_agent(room_id: UUID, request: AddAgentRequest) -> dict[str, object]:
  1060. return await _runtime_call(
  1061. "add_agent", room_id=str(room_id), command=_add_agent_command(request)
  1062. )
  1063. async def update_agent(
  1064. room_id: UUID,
  1065. agent_id: UUID,
  1066. request: UpdateAgentRequest,
  1067. ) -> dict[str, object]:
  1068. return await _runtime_call(
  1069. "update_agent",
  1070. room_id=str(room_id),
  1071. agent_id=str(agent_id),
  1072. command=_command(request, exclude_unset=True),
  1073. )
  1074. async def delete_agent(
  1075. room_id: UUID,
  1076. agent_id: UUID,
  1077. request: RequestIDRequest,
  1078. ) -> dict[str, object]:
  1079. return await _runtime_call(
  1080. "delete_agent",
  1081. room_id=str(room_id),
  1082. agent_id=str(agent_id),
  1083. request_id=str(request.request_id),
  1084. )
  1085. async def interrupt_agent(
  1086. room_id: UUID,
  1087. agent_id: UUID,
  1088. request: RequestIDRequest,
  1089. ) -> object:
  1090. return await _runtime_call(
  1091. "interrupt_agent",
  1092. room_id=str(room_id),
  1093. agent_id=str(agent_id),
  1094. request_id=str(request.request_id),
  1095. )
  1096. async def list_messages(
  1097. room_id: UUID,
  1098. topic_id: UUID | None = Query(default=None, alias="topicId"),
  1099. before_seq: int | None = Query(default=None, alias="beforeSeq", ge=1),
  1100. after_seq: int | None = Query(default=None, alias="afterSeq", ge=1),
  1101. limit: int = Query(default=MAX_MESSAGE_PAGE_SIZE, ge=1, le=MAX_MESSAGE_PAGE_SIZE),
  1102. ) -> dict[str, object]:
  1103. items = await _store_call(
  1104. "list_messages",
  1105. str(room_id),
  1106. topic_id=None if topic_id is None else str(topic_id),
  1107. before_seq=before_seq,
  1108. after_seq=after_seq,
  1109. limit=limit,
  1110. )
  1111. return {"items": items}
  1112. async def upload_group_files(
  1113. room_id: UUID,
  1114. files: Annotated[list[UploadFile], File(alias="file")],
  1115. ) -> dict[str, object]:
  1116. canonical_room_id = str(room_id)
  1117. await _store_call("room_snapshot", canonical_room_id)
  1118. return {
  1119. "files": await _persist_group_uploads(canonical_room_id, files),
  1120. }
  1121. async def send_message(room_id: UUID, request: SendMessageRequest) -> dict[str, object]:
  1122. return await _runtime_call(
  1123. "send_message",
  1124. room_id=str(room_id),
  1125. request_id=str(request.request_id),
  1126. client_message_id=str(request.client_message_id),
  1127. topic_id=None if request.topic_id is None else str(request.topic_id),
  1128. content=request.content,
  1129. mention_agent_ids=[str(agent_id) for agent_id in request.mention_agent_ids],
  1130. )
  1131. async def respond_approval(
  1132. room_id: UUID,
  1133. request: ApprovalRequest,
  1134. interaction_id: str = APIPath(min_length=1, max_length=200),
  1135. ) -> object:
  1136. canonical_interaction_id = _validated_interaction_id(interaction_id)
  1137. return await _runtime_call(
  1138. "respond_approval",
  1139. room_id=str(room_id),
  1140. interaction_id=canonical_interaction_id,
  1141. request_id=str(request.request_id),
  1142. choice=request.choice,
  1143. permanent=request.permanent,
  1144. )
  1145. async def respond_clarification(
  1146. room_id: UUID,
  1147. request: ClarificationRequest,
  1148. interaction_id: str = APIPath(min_length=1, max_length=200),
  1149. ) -> object:
  1150. canonical_interaction_id = _validated_interaction_id(interaction_id)
  1151. return await _runtime_call(
  1152. "respond_clarification",
  1153. room_id=str(room_id),
  1154. interaction_id=canonical_interaction_id,
  1155. request_id=str(request.request_id),
  1156. response=request.response,
  1157. )
  1158. def _node_client_id(request: Request) -> str:
  1159. value = request.headers.get("x-yaoyao-node-client", "").strip().lower()
  1160. try:
  1161. canonical = str(UUID(value))
  1162. except ValueError as error:
  1163. raise GroupInvalidRequest("Paired node client identity is required") from error
  1164. if canonical != value:
  1165. raise GroupInvalidRequest("Paired node client identity is required")
  1166. return canonical
  1167. async def node_worker_open_session(
  1168. payload: NodeWorkerOpenRequest,
  1169. request: Request,
  1170. ) -> object:
  1171. return await _node_worker_call(
  1172. "open_session", payload, _node_client_id(request)
  1173. )
  1174. async def register_paired_node(request: Request) -> object:
  1175. try:
  1176. payload = await request.json()
  1177. except Exception as error:
  1178. raise GroupInvalidRequest("Node registration requires JSON") from error
  1179. if not isinstance(payload, dict):
  1180. raise GroupInvalidRequest("Node registration must be an object")
  1181. return await _node_registry_call("register", payload)
  1182. async def list_paired_nodes() -> dict[str, object]:
  1183. nodes = await _node_registry_call("list")
  1184. return {"items": nodes}
  1185. async def revoke_paired_node(node_id: UUID) -> dict[str, object]:
  1186. removed = await _node_registry_call("revoke", str(node_id))
  1187. if not removed:
  1188. raise GroupInvalidRequest("Paired Hermes node was not found")
  1189. return {"ok": True}
  1190. async def node_worker_submit_prompt(
  1191. runtime_session_id: str,
  1192. payload: NodeWorkerPromptRequest,
  1193. request: Request,
  1194. ) -> dict[str, object]:
  1195. await _node_worker_call(
  1196. "submit_prompt", runtime_session_id, payload, _node_client_id(request)
  1197. )
  1198. return {"status": "streaming"}
  1199. async def node_worker_interrupt(
  1200. runtime_session_id: str,
  1201. request: Request,
  1202. ) -> dict[str, object]:
  1203. await _node_worker_call(
  1204. "interrupt", runtime_session_id, _node_client_id(request)
  1205. )
  1206. return {"status": "interrupted"}
  1207. async def node_worker_close_session(
  1208. runtime_session_id: str,
  1209. request: Request,
  1210. ) -> dict[str, object]:
  1211. closed = await _node_worker_call(
  1212. "close_session", runtime_session_id, _node_client_id(request)
  1213. )
  1214. return {"closed": bool(closed)}
  1215. async def node_worker_respond_approval(
  1216. runtime_session_id: str,
  1217. payload: NodeWorkerApprovalRequest,
  1218. request: Request,
  1219. ) -> dict[str, object]:
  1220. resolved = await _node_worker_call(
  1221. "respond_approval",
  1222. runtime_session_id,
  1223. payload,
  1224. _node_client_id(request),
  1225. )
  1226. return {"resolved": resolved}
  1227. async def node_worker_respond_clarification(
  1228. payload: NodeWorkerClarificationRequest,
  1229. request: Request,
  1230. ) -> dict[str, object]:
  1231. status = await _node_worker_call(
  1232. "respond_clarification", payload, _node_client_id(request)
  1233. )
  1234. return {"status": status}
  1235. async def node_worker_events(
  1236. request: Request,
  1237. after: int = Query(default=0, ge=0),
  1238. runtime_session_id: str | None = Query(
  1239. default=None, alias="runtimeSessionId", min_length=1, max_length=4096
  1240. ),
  1241. limit: int = Query(default=100, ge=1, le=256),
  1242. ) -> object:
  1243. return await _node_worker_call(
  1244. "events",
  1245. after=after,
  1246. runtime_id=runtime_session_id,
  1247. limit=limit,
  1248. client_id=_node_client_id(request),
  1249. )
  1250. async def node_worker_attach_file(
  1251. runtime_session_id: str,
  1252. request: Request,
  1253. ) -> object:
  1254. encoded_name = request.headers.get("x-file-name-b64", "")
  1255. mime_type = request.headers.get("x-mime-type", "application/octet-stream")
  1256. try:
  1257. name = base64.urlsafe_b64decode(
  1258. encoded_name + "=" * (-len(encoded_name) % 4)
  1259. ).decode("utf-8")
  1260. except (ValueError, UnicodeDecodeError) as error:
  1261. raise GroupInvalidRequest("Attachment name header is invalid") from error
  1262. content = await request.body()
  1263. if not content or len(content) > _MAX_GROUP_UPLOAD_FILE_BYTES:
  1264. raise GroupInvalidRequest("Remote attachment content is invalid")
  1265. return await _node_worker_call(
  1266. "attach_file",
  1267. runtime_session_id,
  1268. name=name,
  1269. mime_type=mime_type,
  1270. content=content,
  1271. client_id=_node_client_id(request),
  1272. )
  1273. async def stream_events(websocket: WebSocket) -> None:
  1274. """Delegate the read-only event channel to the authenticated streamer."""
  1275. allowed, close_code = _stream_module.websocket_upgrade_allowed(websocket)
  1276. if not allowed:
  1277. await websocket.close(code=close_code)
  1278. return
  1279. if not await asyncio.to_thread(_plugin_runtime_enabled):
  1280. await websocket.close(code=_stream_module.PLUGIN_DISABLED_CLOSE_CODE)
  1281. return
  1282. async def stream_authorized(
  1283. websocket: WebSocket, store_provider: Callable[[], object]
  1284. ) -> None:
  1285. await _stream_module.stream_authorized_group_events(
  1286. websocket,
  1287. store_provider,
  1288. availability_provider=_plugin_runtime_enabled,
  1289. )
  1290. try:
  1291. manager = _package._yaoyao_group_runtime_manager
  1292. await manager.stream_store(websocket, stream_authorized)
  1293. except RuntimeUnavailableError:
  1294. await websocket.close(code=1013, reason="Group runtime is unavailable")
  1295. def _build_router() -> APIRouter:
  1296. """Build the immutable route table once per resolved plugin directory."""
  1297. built = APIRouter(
  1298. prefix="/v1",
  1299. tags=["yaoyao-group"],
  1300. route_class=GroupAPIRoute,
  1301. lifespan=_router_lifespan,
  1302. )
  1303. built.add_api_route("/capabilities", capabilities, methods=["GET"])
  1304. built.add_api_route("/rooms", list_rooms, methods=["GET"])
  1305. built.add_api_route("/topics/pinned", list_pinned_topics, methods=["GET"])
  1306. built.add_api_route("/rooms", create_room, methods=["POST"])
  1307. built.add_api_route("/rooms/{room_id}", get_room, methods=["GET"])
  1308. built.add_api_route("/rooms/{room_id}", update_room, methods=["PATCH"])
  1309. built.add_api_route("/rooms/{room_id}", archive_room, methods=["DELETE"])
  1310. built.add_api_route("/rooms/{room_id}/restore", restore_room, methods=["POST"])
  1311. built.add_api_route("/rooms/{room_id}/topics", list_topics, methods=["GET"])
  1312. built.add_api_route(
  1313. "/rooms/{room_id}/topics/{topic_id}", update_topic, methods=["PATCH"]
  1314. )
  1315. built.add_api_route(
  1316. "/rooms/{room_id}/topics/{topic_id}", archive_topic, methods=["DELETE"]
  1317. )
  1318. built.add_api_route(
  1319. "/rooms/{room_id}/topics/{topic_id}/restore", restore_topic,
  1320. methods=["POST"],
  1321. )
  1322. built.add_api_route("/rooms/{room_id}/agents", add_agent, methods=["POST"])
  1323. built.add_api_route(
  1324. "/rooms/{room_id}/agents/{agent_id}", update_agent, methods=["PATCH"]
  1325. )
  1326. built.add_api_route(
  1327. "/rooms/{room_id}/agents/{agent_id}", delete_agent, methods=["DELETE"]
  1328. )
  1329. built.add_api_route(
  1330. "/rooms/{room_id}/agents/{agent_id}/interrupt",
  1331. interrupt_agent,
  1332. methods=["POST"],
  1333. )
  1334. built.add_api_route(
  1335. "/rooms/{room_id}/topics/{topic_id}/read",
  1336. mark_topic_read,
  1337. methods=["PATCH"],
  1338. )
  1339. built.add_api_route(
  1340. "/rooms/{room_id}/uploads", upload_group_files, methods=["POST"]
  1341. )
  1342. built.add_api_route("/rooms/{room_id}/messages", list_messages, methods=["GET"])
  1343. built.add_api_route("/rooms/{room_id}/messages", send_message, methods=["POST"])
  1344. built.add_api_route(
  1345. "/rooms/{room_id}/interactions/{interaction_id}/approval",
  1346. respond_approval,
  1347. methods=["POST"],
  1348. )
  1349. built.add_api_route(
  1350. "/rooms/{room_id}/interactions/{interaction_id}/clarification",
  1351. respond_clarification,
  1352. methods=["POST"],
  1353. )
  1354. built.add_api_route("/nodes", register_paired_node, methods=["POST"])
  1355. built.add_api_route("/nodes", list_paired_nodes, methods=["GET"])
  1356. built.add_api_route(
  1357. "/nodes/{node_id}", revoke_paired_node, methods=["DELETE"]
  1358. )
  1359. built.add_api_route(
  1360. "/node-worker/sessions", node_worker_open_session, methods=["POST"]
  1361. )
  1362. built.add_api_route(
  1363. "/node-worker/sessions/{runtime_session_id}/prompt",
  1364. node_worker_submit_prompt,
  1365. methods=["POST"],
  1366. )
  1367. built.add_api_route(
  1368. "/node-worker/sessions/{runtime_session_id}/interrupt",
  1369. node_worker_interrupt,
  1370. methods=["POST"],
  1371. )
  1372. built.add_api_route(
  1373. "/node-worker/sessions/{runtime_session_id}",
  1374. node_worker_close_session,
  1375. methods=["DELETE"],
  1376. )
  1377. built.add_api_route(
  1378. "/node-worker/sessions/{runtime_session_id}/approval",
  1379. node_worker_respond_approval,
  1380. methods=["POST"],
  1381. )
  1382. built.add_api_route(
  1383. "/node-worker/clarification",
  1384. node_worker_respond_clarification,
  1385. methods=["POST"],
  1386. )
  1387. built.add_api_route(
  1388. "/node-worker/events", node_worker_events, methods=["GET"]
  1389. )
  1390. built.add_api_route(
  1391. "/node-worker/sessions/{runtime_session_id}/attachments",
  1392. node_worker_attach_file,
  1393. methods=["POST"],
  1394. )
  1395. built.add_api_websocket_route("/events", stream_events)
  1396. return built
  1397. with _bootstrap_lock:
  1398. router = getattr(_package, "_yaoyao_group_router", None)
  1399. if router is None:
  1400. router = _build_router()
  1401. _package._yaoyao_group_router = router