group_gateway.py 73 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050
  1. """Stable in-process adapter for the Hermes TUI Gateway JSON-RPC API."""
  2. from __future__ import annotations
  3. import copy
  4. import inspect
  5. import logging
  6. import math
  7. import threading
  8. import time
  9. import uuid
  10. from collections import deque
  11. from dataclasses import dataclass, field
  12. from functools import partial
  13. from pathlib import Path
  14. from typing import Any, Callable
  15. logger = logging.getLogger(__name__)
  16. _DELTA_EVENTS = frozenset({
  17. "message.delta",
  18. "reasoning.delta",
  19. "thinking.delta",
  20. })
  21. _APPROVAL_CHOICES = frozenset({"once", "session", "always", "deny"})
  22. MAX_DELTA_BUFFER_BYTES = 64 * 1024
  23. MAX_CONTROL_PAYLOAD_BYTES = 256 * 1024
  24. MAX_TERMINAL_TEXT_BYTES = 64 * 1024
  25. MAX_SAFE_JSON_INTEGER = (1 << 53) - 1
  26. _MAX_DELTA_METADATA_BYTES = 16 * 1024
  27. _MAX_CONTROL_STRING_BYTES = 32 * 1024
  28. _MAX_DIAGNOSTIC_BYTES = 12 * 1024
  29. _MAX_SUMMARY_BYTES = 12 * 1024
  30. _MAX_PREVIEW_BYTES = 32 * 1024
  31. _MAX_TOOL_PREVIEW_BYTES = 16 * 1024
  32. _MAX_ROUTE_FIELD_BYTES = 4096
  33. _MAX_EVENT_TYPE_BYTES = 256
  34. _MAX_RUNTIME_ID_BYTES = 4096
  35. _MAX_JSON_DEPTH = 64
  36. _MAX_JSON_NODES = 100_000
  37. _MAX_JSON_INSPECTION_BYTES = 4 * 1024 * 1024
  38. _MAX_FINITE_JSON_FLOAT = 1.7976931348623157e308
  39. _SESSION_INFO_STRING_LIMITS = {
  40. "model": _MAX_ROUTE_FIELD_BYTES,
  41. "provider": _MAX_ROUTE_FIELD_BYTES,
  42. "reasoning_effort": _MAX_ROUTE_FIELD_BYTES,
  43. "service_tier": _MAX_ROUTE_FIELD_BYTES,
  44. "approval_mode": _MAX_ROUTE_FIELD_BYTES,
  45. "cwd": _MAX_CONTROL_STRING_BYTES,
  46. "branch": _MAX_ROUTE_FIELD_BYTES,
  47. "personality": _MAX_ROUTE_FIELD_BYTES,
  48. "title": _MAX_DIAGNOSTIC_BYTES,
  49. "stored_session_id": _MAX_ROUTE_FIELD_BYTES,
  50. "version": _MAX_ROUTE_FIELD_BYTES,
  51. "release_date": _MAX_ROUTE_FIELD_BYTES,
  52. "update_command": _MAX_DIAGNOSTIC_BYTES,
  53. "credential_warning": _MAX_DIAGNOSTIC_BYTES,
  54. "profile_name": _MAX_ROUTE_FIELD_BYTES,
  55. }
  56. _SESSION_INFO_PREVIEW_LIMITS = {
  57. "tools": _MAX_TOOL_PREVIEW_BYTES,
  58. "skills": _MAX_TOOL_PREVIEW_BYTES,
  59. "project": _MAX_SUMMARY_BYTES,
  60. "usage": _MAX_SUMMARY_BYTES,
  61. "mcp_servers": _MAX_TOOL_PREVIEW_BYTES,
  62. }
  63. _SESSION_INFO_BOOLEAN_FIELDS = ("running", "fast", "yolo")
  64. _SESSION_INFO_INTEGER_FIELDS = ("desktop_contract",)
  65. _SESSION_INFO_OPTIONAL_INTEGER_FIELDS = ("update_behind",)
  66. _SESSION_INFO_DROPPED_FIELDS = frozenset({"system_prompt"})
  67. _SESSION_INFO_EXACT_ROUTE_FIELDS = frozenset({
  68. "stored_session_id",
  69. "profile_name",
  70. "cwd",
  71. })
  72. class GatewayError(RuntimeError):
  73. """Base exception for the plugin's stable Gateway boundary."""
  74. class GatewayValidationError(GatewayError):
  75. """A caller supplied an invalid Gateway argument."""
  76. class GatewayTransportClosed(GatewayError):
  77. """The in-process transport was closed while a request was pending."""
  78. class GatewayTimeoutError(GatewayError):
  79. """A Gateway request did not receive a response before its deadline."""
  80. def __init__(self, method: str) -> None:
  81. self.method = method
  82. super().__init__(f"{method} timed out")
  83. class GatewayDispatchError(GatewayError):
  84. """The core dispatcher failed synchronously."""
  85. def __init__(self, method: str) -> None:
  86. self.method = method
  87. super().__init__(f"{method} dispatcher failed")
  88. class GatewayProtocolError(GatewayError):
  89. """The core dispatcher returned a response outside the stable contract."""
  90. def __init__(self, method: str, detail: str) -> None:
  91. self.method = method
  92. self.detail = detail
  93. super().__init__(f"{method}: {detail}")
  94. class GatewayRPCError(GatewayError):
  95. """A well-formed JSON-RPC error returned by the core Gateway."""
  96. def __init__(
  97. self,
  98. method: str,
  99. code: int,
  100. rpc_message: str,
  101. data: Any = None,
  102. ) -> None:
  103. self.method = method
  104. self.code = code
  105. self.rpc_message = rpc_message
  106. self.data = data
  107. # Do not place core-provided text/data in the display string. HTTP callers
  108. # can safely surface this exception without disclosing internal details.
  109. super().__init__(f"{method} RPC failed ({code})")
  110. @dataclass(frozen=True)
  111. class SessionIdentity:
  112. """Persistent and process-local identities returned by session operations."""
  113. stored_id: str
  114. runtime_id: str
  115. running: bool
  116. @dataclass
  117. class _ResponseWaiter:
  118. request_id: str
  119. method: str
  120. on_late_response: Callable[[str, dict[str, Any]], None] | None = None
  121. event: threading.Event = field(default_factory=threading.Event)
  122. response: dict[str, Any] | None = None
  123. error: BaseException | None = None
  124. @dataclass(frozen=True)
  125. class _LateResponseTombstone:
  126. method: str
  127. expires_at: float
  128. callback: Callable[[str, dict[str, Any]], None]
  129. @dataclass(frozen=True)
  130. class _LateSessionContext:
  131. request_id: str
  132. method: str
  133. profile: str
  134. requested_stored_id: str | None
  135. adoption_generation: int
  136. @dataclass(frozen=True)
  137. class _JsonInspection:
  138. valid: bool
  139. encoded_bytes: int
  140. reason: str = ""
  141. @dataclass
  142. class _QueuedEvent:
  143. runtime_id: str
  144. event_type: str
  145. payload: dict[str, Any]
  146. delta_key: tuple[str, str] | None = None
  147. delta_bytes: int = 0
  148. EventCallback = Callable[..., None]
  149. Dispatcher = Callable[[dict[str, Any], "GroupGatewayTransport"], dict[str, Any] | None]
  150. def _is_safe_json_integer(value: Any) -> bool:
  151. """Return whether an exact integer round-trips through IEEE-754 JSON peers."""
  152. return (
  153. type(value) is int
  154. and value.bit_length() <= MAX_SAFE_JSON_INTEGER.bit_length()
  155. and -MAX_SAFE_JSON_INTEGER <= value <= MAX_SAFE_JSON_INTEGER
  156. )
  157. def _is_safe_json_float(value: Any) -> bool:
  158. """Return whether an exact float is a finite JSON-interoperable scalar."""
  159. return (
  160. type(value) is float
  161. and math.isfinite(value)
  162. and abs(value) <= _MAX_FINITE_JSON_FLOAT
  163. )
  164. def _is_safe_json_scalar(value: Any) -> bool:
  165. return (
  166. value is None
  167. or type(value) is bool
  168. or _is_safe_json_integer(value)
  169. or _is_safe_json_float(value)
  170. )
  171. def _inspect_json_payload(value: Any) -> _JsonInspection:
  172. """Validate strict JSON and measure its UTF-8 encoding without serializing."""
  173. encoded_bytes = 0
  174. nodes = 0
  175. ancestors: set[int] = set()
  176. failure_reason = ""
  177. def consume(size: int) -> bool:
  178. nonlocal encoded_bytes, failure_reason
  179. encoded_bytes += size
  180. if encoded_bytes > _MAX_JSON_INSPECTION_BYTES:
  181. failure_reason = "payload exceeds inspection budget"
  182. return False
  183. return True
  184. def string_size(text: str) -> int | None:
  185. nonlocal failure_reason
  186. if len(text) > _MAX_JSON_INSPECTION_BYTES:
  187. failure_reason = "string exceeds inspection budget"
  188. return None
  189. size = 2
  190. for character in text:
  191. codepoint = ord(character)
  192. if 0xD800 <= codepoint <= 0xDFFF:
  193. failure_reason = "string contains an invalid Unicode surrogate"
  194. return None
  195. if character in {'"', "\\"}:
  196. size += 2
  197. elif codepoint <= 0x1F:
  198. size += 6
  199. else:
  200. size += len(character.encode("utf-8"))
  201. if size > _MAX_JSON_INSPECTION_BYTES:
  202. failure_reason = "string exceeds inspection budget"
  203. return None
  204. return size
  205. def walk(item: Any, depth: int) -> bool:
  206. nonlocal nodes, failure_reason
  207. nodes += 1
  208. if nodes > _MAX_JSON_NODES:
  209. failure_reason = "payload contains too many JSON nodes"
  210. return False
  211. if depth > _MAX_JSON_DEPTH:
  212. failure_reason = "payload exceeds the JSON depth limit"
  213. return False
  214. if item is None:
  215. return consume(4)
  216. if type(item) is bool:
  217. return consume(4 if item else 5)
  218. if type(item) is int:
  219. if not _is_safe_json_integer(item):
  220. failure_reason = "integer exceeds the interoperable JSON range"
  221. return False
  222. return consume(len(str(item)))
  223. if type(item) is float:
  224. if not _is_safe_json_float(item):
  225. failure_reason = "float is outside the finite JSON range"
  226. return False
  227. return consume(len(repr(item)))
  228. if isinstance(item, str):
  229. encoded_size = string_size(item)
  230. return encoded_size is not None and consume(encoded_size)
  231. if isinstance(item, list):
  232. identity = id(item)
  233. if identity in ancestors:
  234. failure_reason = "cyclic list is not JSON"
  235. return False
  236. if not consume(2 + max(0, len(item) - 1)):
  237. return False
  238. ancestors.add(identity)
  239. try:
  240. return all(walk(child, depth + 1) for child in item)
  241. finally:
  242. ancestors.remove(identity)
  243. if isinstance(item, dict):
  244. identity = id(item)
  245. if identity in ancestors:
  246. failure_reason = "cyclic object is not JSON"
  247. return False
  248. if not consume(2 + max(0, len(item) - 1)):
  249. return False
  250. ancestors.add(identity)
  251. try:
  252. for key, child in item.items():
  253. if not isinstance(key, str):
  254. failure_reason = "JSON object keys must be strings"
  255. return False
  256. encoded_size = string_size(key)
  257. if encoded_size is None or not consume(encoded_size + 1):
  258. return False
  259. if not walk(child, depth + 1):
  260. return False
  261. return True
  262. finally:
  263. ancestors.remove(identity)
  264. failure_reason = "payload contains a non-JSON value"
  265. return False
  266. try:
  267. valid = walk(value, 0)
  268. except Exception: # noqa: BLE001 - malformed core payloads are rejected
  269. return _JsonInspection(False, encoded_bytes, "payload validation failed")
  270. return _JsonInspection(valid, encoded_bytes, failure_reason)
  271. def _json_payload_within_limit(value: Any, limit: int) -> bool:
  272. inspection = _inspect_json_payload(value)
  273. return inspection.valid and inspection.encoded_bytes <= limit
  274. def _utf8_chunks(text: str, limit: int):
  275. """Yield non-empty codepoint-safe chunks no larger than ``limit`` bytes."""
  276. if not text:
  277. yield "", 0
  278. return
  279. characters: list[str] = []
  280. encoded_bytes = 0
  281. for character in text:
  282. character_bytes = len(character.encode("utf-8"))
  283. if characters and encoded_bytes + character_bytes > limit:
  284. yield "".join(characters), encoded_bytes
  285. characters = []
  286. encoded_bytes = 0
  287. characters.append(character)
  288. encoded_bytes += character_bytes
  289. if characters:
  290. yield "".join(characters), encoded_bytes
  291. def _bounded_utf8(text: str, limit: int) -> bool:
  292. if len(text) > limit:
  293. return False
  294. try:
  295. return len(text.encode("utf-8")) <= limit
  296. except UnicodeError:
  297. return False
  298. def _utf8_prefix(text: str, limit: int) -> tuple[str, bool]:
  299. characters: list[str] = []
  300. encoded_bytes = 0
  301. try:
  302. for character in text:
  303. character_bytes = len(character.encode("utf-8"))
  304. if encoded_bytes + character_bytes > limit:
  305. return "".join(characters), True
  306. characters.append(character)
  307. encoded_bytes += character_bytes
  308. except UnicodeError:
  309. return "".join(characters), True
  310. return text, False
  311. def _json_string_prefix(text: str, limit: int) -> tuple[str, bool]:
  312. """Bound a string by its strict UTF-8 JSON encoding, including escapes."""
  313. characters: list[str] = []
  314. encoded_bytes = 2 # Opening and closing quotes.
  315. for character in text:
  316. codepoint = ord(character)
  317. if 0xD800 <= codepoint <= 0xDFFF:
  318. return "".join(characters), True
  319. if character in {'"', "\\"}:
  320. character_bytes = 2
  321. elif codepoint <= 0x1F:
  322. character_bytes = 6
  323. else:
  324. character_bytes = len(character.encode("utf-8"))
  325. if encoded_bytes + character_bytes > limit:
  326. return "".join(characters), True
  327. characters.append(character)
  328. encoded_bytes += character_bytes
  329. return text, False
  330. def _contains_control_character(text: str) -> bool:
  331. return any(
  332. ord(character) <= 0x1F or ord(character) == 0x7F
  333. for character in text
  334. )
  335. @dataclass
  336. class _PreviewBudget:
  337. remaining_bytes: int
  338. remaining_nodes: int = 128
  339. truncated: bool = False
  340. def _bounded_json_preview(value: Any, limit: int) -> tuple[Any, bool]:
  341. if _json_payload_within_limit(value, limit):
  342. try:
  343. return copy.deepcopy(value), False
  344. except Exception: # noqa: BLE001 - fall through to the safe projector
  345. pass
  346. budget = _PreviewBudget(limit)
  347. def walk(item: Any, depth: int) -> Any:
  348. budget.remaining_nodes -= 1
  349. if budget.remaining_nodes < 0 or depth > 4:
  350. budget.truncated = True
  351. return "<truncated>"
  352. if item is None or type(item) is bool:
  353. return item
  354. if type(item) is int:
  355. if _is_safe_json_integer(item):
  356. return item
  357. budget.truncated = True
  358. return "<integer outside safe JSON range>"
  359. if type(item) is float:
  360. if _is_safe_json_float(item):
  361. return item
  362. budget.truncated = True
  363. return "<float outside finite JSON range>"
  364. if isinstance(item, str):
  365. per_string_limit = min(8192, max(0, budget.remaining_bytes))
  366. projected, truncated = _utf8_prefix(item, per_string_limit)
  367. budget.remaining_bytes -= len(projected.encode("utf-8"))
  368. budget.truncated = budget.truncated or truncated
  369. return projected
  370. if isinstance(item, list):
  371. projected_items = []
  372. for child in item[:32]:
  373. projected_items.append(walk(child, depth + 1))
  374. if len(item) > len(projected_items):
  375. budget.truncated = True
  376. return projected_items
  377. if isinstance(item, dict):
  378. projected_object: dict[str, Any] = {}
  379. for index, (key, child) in enumerate(item.items()):
  380. if index >= 32 or budget.remaining_nodes <= 0:
  381. budget.truncated = True
  382. break
  383. if not isinstance(key, str):
  384. budget.truncated = True
  385. continue
  386. projected_key, key_truncated = _utf8_prefix(key, 256)
  387. if not projected_key or projected_key in projected_object:
  388. budget.truncated = True
  389. continue
  390. budget.truncated = budget.truncated or key_truncated
  391. projected_object[projected_key] = walk(child, depth + 1)
  392. return projected_object
  393. budget.truncated = True
  394. return "<invalid value>"
  395. projected = walk(value, 0)
  396. if not _json_payload_within_limit(projected, limit):
  397. return "<preview omitted>", True
  398. return projected, True
  399. def _copy_bounded_string(
  400. source: dict[str, Any],
  401. target: dict[str, Any],
  402. field_name: str,
  403. limit: int,
  404. truncated_fields: list[str],
  405. ) -> None:
  406. if field_name not in source:
  407. return
  408. value = source[field_name]
  409. if not isinstance(value, str):
  410. truncated_fields.append(field_name)
  411. return
  412. projected, truncated = _utf8_prefix(value, limit)
  413. target[field_name] = projected
  414. if truncated:
  415. truncated_fields.append(field_name)
  416. def _copy_bounded_preview(
  417. source: dict[str, Any],
  418. target: dict[str, Any],
  419. field_name: str,
  420. limit: int,
  421. truncated_fields: list[str],
  422. ) -> None:
  423. if field_name not in source:
  424. return
  425. projected, truncated = _bounded_json_preview(source[field_name], limit)
  426. target[field_name] = projected
  427. if truncated:
  428. truncated_fields.append(field_name)
  429. def _copy_scalar_fields(
  430. source: dict[str, Any],
  431. target: dict[str, Any],
  432. fields: tuple[str, ...],
  433. invalid_fields: list[str],
  434. ) -> None:
  435. for field_name in fields:
  436. if field_name not in source:
  437. continue
  438. value = source[field_name]
  439. if _is_safe_json_scalar(value):
  440. target[field_name] = value
  441. else:
  442. invalid_fields.append(field_name)
  443. def _is_session_info_scalar_valid(field_name: str, value: Any) -> bool:
  444. if field_name in _SESSION_INFO_BOOLEAN_FIELDS:
  445. return type(value) is bool
  446. if field_name in _SESSION_INFO_INTEGER_FIELDS:
  447. return _is_safe_json_integer(value)
  448. if field_name in _SESSION_INFO_OPTIONAL_INTEGER_FIELDS:
  449. return value is None or _is_safe_json_integer(value)
  450. return False
  451. def _copy_session_info_scalars(
  452. source: dict[str, Any],
  453. target: dict[str, Any],
  454. invalid_fields: list[str],
  455. ) -> None:
  456. scalar_fields = (
  457. *_SESSION_INFO_BOOLEAN_FIELDS,
  458. *_SESSION_INFO_INTEGER_FIELDS,
  459. *_SESSION_INFO_OPTIONAL_INTEGER_FIELDS,
  460. )
  461. for field_name in scalar_fields:
  462. if field_name not in source:
  463. continue
  464. value = source[field_name]
  465. if _is_session_info_scalar_valid(field_name, value):
  466. target[field_name] = value
  467. else:
  468. invalid_fields.append(field_name)
  469. def _copy_session_info_string(
  470. source: dict[str, Any],
  471. target: dict[str, Any],
  472. field_name: str,
  473. limit: int,
  474. truncated_fields: list[str],
  475. invalid_fields: list[str],
  476. ) -> None:
  477. if field_name not in source:
  478. return
  479. value = source[field_name]
  480. if not isinstance(value, str):
  481. invalid_fields.append(field_name)
  482. return
  483. if field_name in _SESSION_INFO_EXACT_ROUTE_FIELDS:
  484. if _contains_control_character(value):
  485. invalid_fields.append(field_name)
  486. return
  487. if not _json_payload_within_limit(value, limit):
  488. truncated_fields.append(field_name)
  489. return
  490. target[field_name] = value
  491. return
  492. projected, truncated = _json_string_prefix(value, limit)
  493. target[field_name] = projected
  494. if truncated:
  495. truncated_fields.append(field_name)
  496. def _append_dropped_fields(
  497. source: dict[str, Any],
  498. selected: set[str],
  499. truncated_fields: list[str],
  500. ) -> None:
  501. for field_name in source:
  502. if not isinstance(field_name, str) or field_name in selected:
  503. continue
  504. if len(truncated_fields) >= 64:
  505. break
  506. projected_name, _truncated = _utf8_prefix(field_name, 256)
  507. if projected_name:
  508. truncated_fields.append(projected_name)
  509. def _mark_projected_control(
  510. payload: dict[str, Any],
  511. inspection: _JsonInspection,
  512. truncated_fields: list[str],
  513. invalid_fields: list[str],
  514. ) -> dict[str, Any]:
  515. if inspection.valid:
  516. payload["transportTruncated"] = True
  517. payload["originalBytes"] = inspection.encoded_bytes
  518. payload["transportWarning"] = "Gateway control payload was safely truncated"
  519. else:
  520. payload["transportInvalidPayload"] = True
  521. payload["transportWarning"] = (
  522. "Gateway control payload was invalid and safely projected"
  523. )
  524. unique_fields = list(dict.fromkeys(truncated_fields))[:64]
  525. if unique_fields:
  526. payload["truncatedFields"] = unique_fields
  527. unique_invalid_fields = list(dict.fromkeys(invalid_fields))[:64]
  528. if unique_invalid_fields:
  529. payload["invalidFields"] = unique_invalid_fields
  530. return payload
  531. def _project_message_control(
  532. source: dict[str, Any],
  533. inspection: _JsonInspection,
  534. ) -> dict[str, Any]:
  535. projected: dict[str, Any] = {}
  536. truncated_fields: list[str] = []
  537. invalid_fields: list[str] = []
  538. string_limits = {
  539. "status": 256,
  540. "text": MAX_TERMINAL_TEXT_BYTES,
  541. "reasoning": MAX_TERMINAL_TEXT_BYTES,
  542. "error": _MAX_DIAGNOSTIC_BYTES,
  543. "failure_reason": _MAX_DIAGNOSTIC_BYTES,
  544. "warning": _MAX_DIAGNOSTIC_BYTES,
  545. "message": _MAX_DIAGNOSTIC_BYTES,
  546. }
  547. for field_name, limit in string_limits.items():
  548. _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
  549. _copy_scalar_fields(
  550. source,
  551. projected,
  552. ("recoverable", "partial", "response_previewed"),
  553. invalid_fields,
  554. )
  555. for field_name in ("usage", "billing"):
  556. _copy_bounded_preview(
  557. source, projected, field_name, _MAX_SUMMARY_BYTES, truncated_fields
  558. )
  559. selected = set(string_limits) | {
  560. "recoverable",
  561. "partial",
  562. "response_previewed",
  563. "usage",
  564. "billing",
  565. }
  566. _append_dropped_fields(source, selected, truncated_fields)
  567. return _mark_projected_control(
  568. projected, inspection, truncated_fields, invalid_fields
  569. )
  570. def _project_interaction_control(
  571. source: dict[str, Any],
  572. inspection: _JsonInspection,
  573. ) -> dict[str, Any]:
  574. projected: dict[str, Any] = {}
  575. truncated_fields: list[str] = []
  576. invalid_fields: list[str] = []
  577. string_limits = {
  578. "request_id": _MAX_ROUTE_FIELD_BYTES,
  579. "interaction_id": _MAX_ROUTE_FIELD_BYTES,
  580. "question": _MAX_CONTROL_STRING_BYTES,
  581. "command": _MAX_CONTROL_STRING_BYTES,
  582. "reason": _MAX_DIAGNOSTIC_BYTES,
  583. "message": _MAX_DIAGNOSTIC_BYTES,
  584. "description": _MAX_DIAGNOSTIC_BYTES,
  585. "tool": _MAX_ROUTE_FIELD_BYTES,
  586. "name": _MAX_ROUTE_FIELD_BYTES,
  587. }
  588. for field_name, limit in string_limits.items():
  589. _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
  590. for field_name in ("choices", "flags"):
  591. _copy_bounded_preview(
  592. source, projected, field_name, _MAX_PREVIEW_BYTES, truncated_fields
  593. )
  594. scalar_fields = (
  595. "allow_permanent",
  596. "smart_denied",
  597. "multi_select",
  598. "redacted",
  599. "all",
  600. )
  601. _copy_scalar_fields(source, projected, scalar_fields, invalid_fields)
  602. selected = set(string_limits) | {"choices", "flags", *scalar_fields}
  603. _append_dropped_fields(source, selected, truncated_fields)
  604. return _mark_projected_control(
  605. projected, inspection, truncated_fields, invalid_fields
  606. )
  607. def _project_tool_control(
  608. source: dict[str, Any],
  609. inspection: _JsonInspection,
  610. ) -> dict[str, Any]:
  611. projected: dict[str, Any] = {}
  612. truncated_fields: list[str] = []
  613. invalid_fields: list[str] = []
  614. string_limits = {
  615. "tool_id": _MAX_ROUTE_FIELD_BYTES,
  616. "id": _MAX_ROUTE_FIELD_BYTES,
  617. "name": _MAX_ROUTE_FIELD_BYTES,
  618. "status": 256,
  619. "risk": _MAX_ROUTE_FIELD_BYTES,
  620. "summary": _MAX_SUMMARY_BYTES,
  621. "context": _MAX_SUMMARY_BYTES,
  622. "error": _MAX_DIAGNOSTIC_BYTES,
  623. "warning": _MAX_DIAGNOSTIC_BYTES,
  624. "args_text": _MAX_TOOL_PREVIEW_BYTES,
  625. "result_text": _MAX_TOOL_PREVIEW_BYTES,
  626. "inline_diff": _MAX_TOOL_PREVIEW_BYTES,
  627. }
  628. for field_name, limit in string_limits.items():
  629. _copy_bounded_string(source, projected, field_name, limit, truncated_fields)
  630. for field_name in ("args", "result", "findings", "todos", "metadata"):
  631. _copy_bounded_preview(
  632. source, projected, field_name, _MAX_TOOL_PREVIEW_BYTES, truncated_fields
  633. )
  634. scalar_fields = ("redacted", "duration_s", "progress")
  635. _copy_scalar_fields(source, projected, scalar_fields, invalid_fields)
  636. selected = set(string_limits) | {
  637. "args",
  638. "result",
  639. "findings",
  640. "todos",
  641. "metadata",
  642. *scalar_fields,
  643. }
  644. _append_dropped_fields(source, selected, truncated_fields)
  645. return _mark_projected_control(
  646. projected, inspection, truncated_fields, invalid_fields
  647. )
  648. def _project_session_info_control(
  649. source: dict[str, Any],
  650. inspection: _JsonInspection,
  651. ) -> dict[str, Any]:
  652. """Keep session routing and settle state when diagnostics exceed the wire cap."""
  653. projected: dict[str, Any] = {}
  654. truncated_fields: list[str] = []
  655. invalid_fields: list[str] = []
  656. for field_name, limit in _SESSION_INFO_STRING_LIMITS.items():
  657. _copy_session_info_string(
  658. source,
  659. projected,
  660. field_name,
  661. limit,
  662. truncated_fields,
  663. invalid_fields,
  664. )
  665. _copy_session_info_scalars(source, projected, invalid_fields)
  666. for field_name, limit in _SESSION_INFO_PREVIEW_LIMITS.items():
  667. _copy_bounded_preview(
  668. source, projected, field_name, limit, truncated_fields
  669. )
  670. for field_name in _SESSION_INFO_DROPPED_FIELDS:
  671. if field_name in source:
  672. truncated_fields.append(field_name)
  673. selected = (
  674. set(_SESSION_INFO_STRING_LIMITS)
  675. | set(_SESSION_INFO_PREVIEW_LIMITS)
  676. | set(_SESSION_INFO_BOOLEAN_FIELDS)
  677. | set(_SESSION_INFO_INTEGER_FIELDS)
  678. | set(_SESSION_INFO_OPTIONAL_INTEGER_FIELDS)
  679. | set(_SESSION_INFO_DROPPED_FIELDS)
  680. )
  681. _append_dropped_fields(source, selected, truncated_fields)
  682. marked = _mark_projected_control(
  683. projected, inspection, truncated_fields, invalid_fields
  684. )
  685. if invalid_fields:
  686. marked["transportInvalidPayload"] = True
  687. return marked
  688. def _control_payload_sentinel(inspection: _JsonInspection) -> dict[str, Any]:
  689. if not inspection.valid:
  690. return {
  691. "transportInvalidPayload": True,
  692. "transportWarning": "Gateway control payload was invalid and replaced",
  693. }
  694. return {
  695. "transportTruncated": True,
  696. "originalBytes": inspection.encoded_bytes,
  697. "transportWarning": "Gateway control payload was replaced at the size limit",
  698. }
  699. def _sanitize_control_payload(
  700. event_type: str,
  701. source: Any,
  702. inspection: _JsonInspection,
  703. ) -> dict[str, Any]:
  704. if not isinstance(source, dict):
  705. return _control_payload_sentinel(inspection)
  706. if event_type == "session.info":
  707. projected = _project_session_info_control(source, inspection)
  708. elif (
  709. event_type == "message.complete"
  710. or event_type == "error"
  711. or event_type.endswith((".error", ".failed"))
  712. ):
  713. projected = _project_message_control(source, inspection)
  714. elif event_type == "approval.request" or event_type in {
  715. "clarify.request",
  716. "clarify.expire",
  717. }:
  718. projected = _project_interaction_control(source, inspection)
  719. elif event_type.startswith("tool."):
  720. projected = _project_tool_control(source, inspection)
  721. else:
  722. return _control_payload_sentinel(inspection)
  723. if _json_payload_within_limit(projected, MAX_CONTROL_PAYLOAD_BYTES):
  724. return projected
  725. logger.error("Gateway control projection exceeded its hard byte limit")
  726. return _control_payload_sentinel(
  727. _JsonInspection(False, inspection.encoded_bytes, "projection exceeded limit")
  728. )
  729. def _control_requires_projection(
  730. event_type: str,
  731. source: Any,
  732. inspection: _JsonInspection,
  733. ) -> bool:
  734. if (
  735. not isinstance(source, dict)
  736. or not inspection.valid
  737. or inspection.encoded_bytes > MAX_CONTROL_PAYLOAD_BYTES
  738. ):
  739. return True
  740. limits: dict[str, int] = {}
  741. preview_limits: dict[str, int] = {}
  742. scalar_fields: tuple[str, ...] = ()
  743. typed_session_info_scalars = False
  744. if event_type == "session.info":
  745. limits = _SESSION_INFO_STRING_LIMITS
  746. preview_limits = _SESSION_INFO_PREVIEW_LIMITS
  747. typed_session_info_scalars = True
  748. elif (
  749. event_type == "message.complete"
  750. or event_type == "error"
  751. or event_type.endswith((".error", ".failed"))
  752. ):
  753. limits = {
  754. "status": 256,
  755. "text": MAX_TERMINAL_TEXT_BYTES,
  756. "reasoning": MAX_TERMINAL_TEXT_BYTES,
  757. "error": _MAX_DIAGNOSTIC_BYTES,
  758. "failure_reason": _MAX_DIAGNOSTIC_BYTES,
  759. "warning": _MAX_DIAGNOSTIC_BYTES,
  760. "message": _MAX_DIAGNOSTIC_BYTES,
  761. }
  762. preview_limits = {"usage": _MAX_SUMMARY_BYTES, "billing": _MAX_SUMMARY_BYTES}
  763. scalar_fields = ("recoverable", "partial", "response_previewed")
  764. elif event_type == "approval.request" or event_type in {
  765. "clarify.request",
  766. "clarify.expire",
  767. }:
  768. limits = {
  769. "request_id": _MAX_ROUTE_FIELD_BYTES,
  770. "interaction_id": _MAX_ROUTE_FIELD_BYTES,
  771. "question": _MAX_CONTROL_STRING_BYTES,
  772. "command": _MAX_CONTROL_STRING_BYTES,
  773. "reason": _MAX_DIAGNOSTIC_BYTES,
  774. "message": _MAX_DIAGNOSTIC_BYTES,
  775. "description": _MAX_DIAGNOSTIC_BYTES,
  776. "tool": _MAX_ROUTE_FIELD_BYTES,
  777. "name": _MAX_ROUTE_FIELD_BYTES,
  778. }
  779. preview_limits = {
  780. "choices": _MAX_PREVIEW_BYTES,
  781. "flags": _MAX_PREVIEW_BYTES,
  782. }
  783. scalar_fields = (
  784. "allow_permanent",
  785. "smart_denied",
  786. "multi_select",
  787. "redacted",
  788. "all",
  789. )
  790. elif event_type.startswith("tool."):
  791. limits = {
  792. "tool_id": _MAX_ROUTE_FIELD_BYTES,
  793. "id": _MAX_ROUTE_FIELD_BYTES,
  794. "name": _MAX_ROUTE_FIELD_BYTES,
  795. "status": 256,
  796. "risk": _MAX_ROUTE_FIELD_BYTES,
  797. "summary": _MAX_SUMMARY_BYTES,
  798. "context": _MAX_SUMMARY_BYTES,
  799. "error": _MAX_DIAGNOSTIC_BYTES,
  800. "warning": _MAX_DIAGNOSTIC_BYTES,
  801. "args_text": _MAX_TOOL_PREVIEW_BYTES,
  802. "result_text": _MAX_TOOL_PREVIEW_BYTES,
  803. "inline_diff": _MAX_TOOL_PREVIEW_BYTES,
  804. }
  805. preview_limits = {
  806. field_name: _MAX_TOOL_PREVIEW_BYTES
  807. for field_name in ("args", "result", "findings", "todos", "metadata")
  808. }
  809. scalar_fields = ("redacted", "duration_s", "progress")
  810. for field_name, limit in limits.items():
  811. if field_name not in source:
  812. continue
  813. value = source[field_name]
  814. if not isinstance(value, str):
  815. return True
  816. if event_type == "session.info":
  817. if (
  818. field_name in _SESSION_INFO_EXACT_ROUTE_FIELDS
  819. and _contains_control_character(value)
  820. ):
  821. return True
  822. if not _json_payload_within_limit(value, limit):
  823. return True
  824. elif not _bounded_utf8(value, limit):
  825. return True
  826. if any(
  827. field_name in source
  828. and not _json_payload_within_limit(source[field_name], limit)
  829. for field_name, limit in preview_limits.items()
  830. ):
  831. return True
  832. if typed_session_info_scalars:
  833. typed_fields = (
  834. *_SESSION_INFO_BOOLEAN_FIELDS,
  835. *_SESSION_INFO_INTEGER_FIELDS,
  836. *_SESSION_INFO_OPTIONAL_INTEGER_FIELDS,
  837. )
  838. return any(
  839. field_name in source
  840. and not _is_session_info_scalar_valid(field_name, source[field_name])
  841. for field_name in typed_fields
  842. )
  843. return any(
  844. field_name in source and not _is_safe_json_scalar(source[field_name])
  845. for field_name in scalar_fields
  846. )
  847. class GroupGatewayTransport:
  848. """Thread-safe response router and bounded serial event transport."""
  849. def __init__(
  850. self,
  851. on_event: EventCallback,
  852. *,
  853. max_control_frames: int = 128,
  854. late_response_ttl: float = 30.0,
  855. max_late_responses: int = 128,
  856. ) -> None:
  857. if not callable(on_event):
  858. raise GatewayValidationError("on_event must be callable")
  859. if (
  860. isinstance(max_control_frames, bool)
  861. or not isinstance(max_control_frames, int)
  862. or max_control_frames < 1
  863. ):
  864. raise GatewayValidationError(
  865. "max_control_frames must be a positive integer"
  866. )
  867. if (
  868. isinstance(late_response_ttl, bool)
  869. or not isinstance(late_response_ttl, (int, float))
  870. or not math.isfinite(late_response_ttl)
  871. or late_response_ttl <= 0
  872. ):
  873. raise GatewayValidationError("late_response_ttl must be positive")
  874. if (
  875. isinstance(max_late_responses, bool)
  876. or not isinstance(max_late_responses, int)
  877. or max_late_responses < 1
  878. ):
  879. raise GatewayValidationError(
  880. "max_late_responses must be a positive integer"
  881. )
  882. self._on_event = self._normalize_callback(on_event)
  883. self._max_queued_frames = max_control_frames
  884. self._late_response_ttl = float(late_response_ttl)
  885. self._max_late_responses = max_late_responses
  886. self._condition = threading.Condition(threading.RLock())
  887. self._closed = False
  888. self._waiters: dict[str, _ResponseWaiter] = {}
  889. self._tombstones: dict[str, _LateResponseTombstone] = {}
  890. self._events: deque[_QueuedEvent] = deque()
  891. self._callback_active = False
  892. self._worker = threading.Thread(
  893. target=self._event_worker,
  894. name="yaoyao-group-gateway-events",
  895. daemon=True,
  896. )
  897. self._worker.start()
  898. @staticmethod
  899. def _normalize_callback(on_event: EventCallback) -> EventCallback:
  900. try:
  901. signature = inspect.signature(on_event)
  902. except (TypeError, ValueError):
  903. return on_event
  904. try:
  905. signature.bind("runtime", "event.type", {})
  906. except TypeError:
  907. try:
  908. signature.bind("runtime", {"type": "event.type", "payload": {}})
  909. except TypeError as exc:
  910. raise GatewayValidationError(
  911. "on_event must accept either two or three positional arguments"
  912. ) from exc
  913. def design_callback(
  914. runtime_id: str, event_type: str, payload: dict[str, Any]
  915. ) -> None:
  916. on_event(runtime_id, {"type": event_type, "payload": payload})
  917. return design_callback
  918. return on_event
  919. @property
  920. def closed(self) -> bool:
  921. with self._condition:
  922. return self._closed
  923. @property
  924. def pending_waiter_count(self) -> int:
  925. with self._condition:
  926. return len(self._waiters)
  927. @property
  928. def pending_tombstone_count(self) -> int:
  929. with self._condition:
  930. self._prune_tombstones_locked(time.monotonic())
  931. return len(self._tombstones)
  932. def register_waiter(
  933. self,
  934. request_id: str,
  935. *,
  936. method: str = "request",
  937. on_late_response: Callable[[str, dict[str, Any]], None] | None = None,
  938. ) -> _ResponseWaiter:
  939. if on_late_response is not None and not callable(on_late_response):
  940. raise GatewayValidationError("on_late_response must be callable")
  941. waiter = _ResponseWaiter(
  942. request_id=request_id,
  943. method=method,
  944. on_late_response=on_late_response,
  945. )
  946. with self._condition:
  947. if self._closed:
  948. raise GatewayTransportClosed("Gateway transport is closed")
  949. self._prune_tombstones_locked(time.monotonic())
  950. if request_id in self._waiters or request_id in self._tombstones:
  951. raise GatewayProtocolError("request", "duplicate request id")
  952. self._waiters[request_id] = waiter
  953. return waiter
  954. def cancel_waiter(self, waiter: _ResponseWaiter) -> None:
  955. with self._condition:
  956. current = self._waiters.get(waiter.request_id)
  957. if current is waiter:
  958. del self._waiters[waiter.request_id]
  959. def wait_for_response(
  960. self,
  961. waiter: _ResponseWaiter,
  962. *,
  963. timeout: float,
  964. method: str,
  965. ) -> dict[str, Any]:
  966. if not waiter.event.wait(timeout):
  967. with self._condition:
  968. current = self._waiters.get(waiter.request_id)
  969. if current is waiter:
  970. del self._waiters[waiter.request_id]
  971. if waiter.on_late_response is not None:
  972. self._add_tombstone_locked(waiter, time.monotonic())
  973. raise GatewayTimeoutError(method)
  974. # A response or close won the timeout race while this thread
  975. # was acquiring the condition. Its waiter state is complete.
  976. if waiter.error is not None:
  977. raise waiter.error
  978. if waiter.response is None:
  979. raise GatewayProtocolError(
  980. method, "response waiter completed without a response"
  981. )
  982. return waiter.response
  983. def write(self, obj: dict[str, Any]) -> bool:
  984. """Route a core response or event; return false after close."""
  985. with self._condition:
  986. if self._closed:
  987. return False
  988. if not isinstance(obj, dict):
  989. return True
  990. if "id" in obj and obj.get("method") != "event":
  991. self._write_response(obj)
  992. return True
  993. if obj.get("method") == "event":
  994. event = self._parse_event(obj)
  995. if event is not None:
  996. return self._enqueue_event(event)
  997. return True
  998. def _write_response(self, response: dict[str, Any]) -> None:
  999. request_id = response.get("id")
  1000. if not isinstance(request_id, str):
  1001. return
  1002. late_callback: Callable[[str, dict[str, Any]], None] | None = None
  1003. late_method = ""
  1004. late_response: dict[str, Any] | None = None
  1005. with self._condition:
  1006. if self._closed:
  1007. return
  1008. self._prune_tombstones_locked(time.monotonic())
  1009. waiter = self._waiters.pop(request_id, None)
  1010. if waiter is not None:
  1011. try:
  1012. waiter.response = copy.deepcopy(response)
  1013. except Exception: # noqa: BLE001 - malformed core objects are ignored safely
  1014. waiter.response = dict(response)
  1015. waiter.event.set()
  1016. return
  1017. tombstone = self._tombstones.pop(request_id, None)
  1018. if tombstone is not None:
  1019. late_callback = tombstone.callback
  1020. late_method = tombstone.method
  1021. try:
  1022. late_response = copy.deepcopy(response)
  1023. except Exception: # noqa: BLE001 - malformed late results are ignored
  1024. late_response = dict(response)
  1025. if late_callback is not None and late_response is not None:
  1026. try:
  1027. late_callback(late_method, late_response)
  1028. except Exception: # noqa: BLE001 - never bind core response writers
  1029. logger.exception("YaoYao group late Gateway response callback failed")
  1030. def _prune_tombstones_locked(self, now: float) -> None:
  1031. expired = [
  1032. request_id
  1033. for request_id, tombstone in self._tombstones.items()
  1034. if tombstone.expires_at <= now
  1035. ]
  1036. for request_id in expired:
  1037. self._tombstones.pop(request_id, None)
  1038. def _add_tombstone_locked(self, waiter: _ResponseWaiter, now: float) -> None:
  1039. callback = waiter.on_late_response
  1040. if callback is None:
  1041. return
  1042. self._prune_tombstones_locked(now)
  1043. while len(self._tombstones) >= self._max_late_responses:
  1044. oldest = next(iter(self._tombstones))
  1045. del self._tombstones[oldest]
  1046. logger.warning("Evicted an expired-session cleanup tombstone at capacity")
  1047. self._tombstones[waiter.request_id] = _LateResponseTombstone(
  1048. method=waiter.method,
  1049. expires_at=now + self._late_response_ttl,
  1050. callback=callback,
  1051. )
  1052. self._condition.notify_all()
  1053. @staticmethod
  1054. def _parse_event(frame: dict[str, Any]) -> _QueuedEvent | None:
  1055. params = frame.get("params")
  1056. if not isinstance(params, dict):
  1057. return None
  1058. runtime_id = params.get("session_id")
  1059. event_type = params.get("type")
  1060. if not isinstance(runtime_id, str) or not runtime_id:
  1061. return None
  1062. if not isinstance(event_type, str) or not event_type:
  1063. return None
  1064. if not _bounded_utf8(runtime_id, _MAX_RUNTIME_ID_BYTES) or not _bounded_utf8(
  1065. event_type, _MAX_EVENT_TYPE_BYTES
  1066. ):
  1067. logger.warning("Rejected oversized YaoYao group Gateway event identity")
  1068. return None
  1069. raw_payload = params.get("payload", {})
  1070. if raw_payload is None:
  1071. raw_payload = {}
  1072. delta_key: tuple[str, str] | None = None
  1073. if event_type in _DELTA_EVENTS:
  1074. if not isinstance(raw_payload, dict):
  1075. return None
  1076. text = raw_payload.get("text")
  1077. if not isinstance(text, str):
  1078. return None
  1079. if any(0xD800 <= ord(character) <= 0xDFFF for character in text):
  1080. logger.warning("Rejected non-UTF-8 Gateway delta text")
  1081. return None
  1082. metadata = dict(raw_payload)
  1083. metadata["text"] = ""
  1084. if not _json_payload_within_limit(metadata, _MAX_DELTA_METADATA_BYTES):
  1085. logger.warning("Rejected oversized or non-JSON Gateway delta metadata")
  1086. return None
  1087. delta_key = (runtime_id, event_type)
  1088. try:
  1089. payload = copy.deepcopy(raw_payload)
  1090. except Exception: # noqa: BLE001 - malformed delta objects are ignored safely
  1091. return None
  1092. else:
  1093. inspection = _inspect_json_payload(raw_payload)
  1094. if inspection.valid and not isinstance(raw_payload, dict):
  1095. inspection = _JsonInspection(
  1096. False,
  1097. inspection.encoded_bytes,
  1098. "event payload must be a JSON object",
  1099. )
  1100. if (
  1101. isinstance(raw_payload, dict)
  1102. and inspection.valid
  1103. and not _control_requires_projection(
  1104. event_type,
  1105. raw_payload,
  1106. inspection,
  1107. )
  1108. ):
  1109. try:
  1110. payload = copy.deepcopy(raw_payload)
  1111. except Exception: # noqa: BLE001 - deliver an invalid sentinel instead
  1112. payload = _control_payload_sentinel(
  1113. _JsonInspection(False, 0, "payload copy failed")
  1114. )
  1115. else:
  1116. payload = _sanitize_control_payload(
  1117. event_type,
  1118. raw_payload,
  1119. inspection,
  1120. )
  1121. return _QueuedEvent(runtime_id, event_type, payload, delta_key)
  1122. def _enqueue_event(self, event: _QueuedEvent) -> bool:
  1123. if event.delta_key is not None:
  1124. return self._enqueue_delta(event)
  1125. return self._enqueue_control(event)
  1126. def _enqueue_delta(self, event: _QueuedEvent) -> bool:
  1127. source_text = event.payload["text"]
  1128. for chunk, chunk_bytes in _utf8_chunks(source_text, MAX_DELTA_BUFFER_BYTES):
  1129. payload = dict(event.payload)
  1130. payload["text"] = chunk
  1131. queued = _QueuedEvent(
  1132. event.runtime_id,
  1133. event.event_type,
  1134. payload,
  1135. event.delta_key,
  1136. chunk_bytes,
  1137. )
  1138. with self._condition:
  1139. while True:
  1140. if self._closed:
  1141. return False
  1142. previous = self._events[-1] if self._events else None
  1143. if (
  1144. previous is not None
  1145. and previous.delta_key == queued.delta_key
  1146. and previous.delta_bytes + queued.delta_bytes
  1147. <= MAX_DELTA_BUFFER_BYTES
  1148. ):
  1149. merged_text = previous.payload["text"] + queued.payload["text"]
  1150. previous.payload.update(queued.payload)
  1151. previous.payload["text"] = merged_text
  1152. previous.delta_bytes += queued.delta_bytes
  1153. break
  1154. if len(self._events) < self._max_queued_frames:
  1155. self._events.append(queued)
  1156. self._condition.notify_all()
  1157. break
  1158. self._condition.wait()
  1159. return True
  1160. def _enqueue_control(self, event: _QueuedEvent) -> bool:
  1161. with self._condition:
  1162. if self._closed:
  1163. return False
  1164. while len(self._events) >= self._max_queued_frames and not self._closed:
  1165. self._condition.wait()
  1166. if self._closed:
  1167. return False
  1168. self._events.append(event)
  1169. self._condition.notify_all()
  1170. return True
  1171. def _event_worker(self) -> None:
  1172. while True:
  1173. with self._condition:
  1174. while not self._events and not self._closed:
  1175. now = time.monotonic()
  1176. self._prune_tombstones_locked(now)
  1177. if self._tombstones:
  1178. next_expiry = min(
  1179. tombstone.expires_at
  1180. for tombstone in self._tombstones.values()
  1181. )
  1182. self._condition.wait(max(0.0, next_expiry - now))
  1183. else:
  1184. self._condition.wait()
  1185. if self._closed:
  1186. return
  1187. event = self._events.popleft()
  1188. self._callback_active = True
  1189. self._condition.notify_all()
  1190. try:
  1191. self._on_event(event.runtime_id, event.event_type, event.payload)
  1192. except Exception: # noqa: BLE001 - isolate plugin consumer failures
  1193. logger.exception("YaoYao group Gateway event callback failed")
  1194. finally:
  1195. with self._condition:
  1196. self._callback_active = False
  1197. self._condition.notify_all()
  1198. def drain(self, timeout: float | None = None) -> bool:
  1199. """Wait until queued callbacks finish; intended for orderly shutdown/tests."""
  1200. if timeout is not None and timeout < 0:
  1201. raise GatewayValidationError("drain timeout must be non-negative")
  1202. deadline = None if timeout is None else time.monotonic() + timeout
  1203. with self._condition:
  1204. if threading.current_thread() is self._worker:
  1205. return not self._events
  1206. while self._events or self._callback_active:
  1207. if deadline is None:
  1208. self._condition.wait()
  1209. continue
  1210. remaining = deadline - time.monotonic()
  1211. if remaining <= 0:
  1212. return False
  1213. self._condition.wait(remaining)
  1214. return True
  1215. def close(self) -> None:
  1216. waiters: list[_ResponseWaiter]
  1217. with self._condition:
  1218. if not self._closed:
  1219. self._closed = True
  1220. waiters = list(self._waiters.values())
  1221. self._waiters.clear()
  1222. self._tombstones.clear()
  1223. self._events.clear()
  1224. for waiter in waiters:
  1225. waiter.error = GatewayTransportClosed("Gateway transport is closed")
  1226. waiter.event.set()
  1227. self._condition.notify_all()
  1228. if threading.current_thread() is not self._worker:
  1229. self._worker.join()
  1230. def _default_dispatcher(
  1231. request: dict[str, Any],
  1232. transport: GroupGatewayTransport,
  1233. ) -> dict[str, Any] | None:
  1234. # tui_gateway.server is intentionally private and expensive. Keep the import
  1235. # inside this one boundary and defer it until the first actual request.
  1236. from tui_gateway.server import dispatch
  1237. return dispatch(request, transport)
  1238. def _nonempty_string(value: Any, field_name: str) -> str:
  1239. if not isinstance(value, str) or not value.strip():
  1240. raise GatewayValidationError(f"{field_name} must be a non-empty string")
  1241. return value
  1242. def _response_string(result: dict[str, Any], field_name: str, method: str) -> str:
  1243. value = result.get(field_name)
  1244. if not isinstance(value, str) or not value:
  1245. raise GatewayProtocolError(method, f"missing or invalid {field_name}")
  1246. return value
  1247. def _response_bool(
  1248. result: dict[str, Any],
  1249. field_name: str,
  1250. method: str,
  1251. *,
  1252. default: bool | None = None,
  1253. ) -> bool:
  1254. if field_name not in result and default is not None:
  1255. return default
  1256. value = result.get(field_name)
  1257. if not isinstance(value, bool):
  1258. raise GatewayProtocolError(method, f"missing or invalid {field_name}")
  1259. return value
  1260. class GroupGatewayAdapter:
  1261. """Stable plugin facade over ``tui_gateway.server.dispatch``."""
  1262. def __init__(
  1263. self,
  1264. *,
  1265. dispatcher: Dispatcher | None = None,
  1266. on_event: EventCallback | None = None,
  1267. request_timeout: float = 30.0,
  1268. max_control_frames: int = 128,
  1269. late_response_ttl: float = 30.0,
  1270. max_late_responses: int = 128,
  1271. ) -> None:
  1272. if dispatcher is not None and not callable(dispatcher):
  1273. raise GatewayValidationError("dispatcher must be callable")
  1274. if (
  1275. isinstance(request_timeout, bool)
  1276. or not isinstance(request_timeout, (int, float))
  1277. or not math.isfinite(request_timeout)
  1278. or request_timeout <= 0
  1279. ):
  1280. raise GatewayValidationError("request_timeout must be positive")
  1281. self._dispatcher = _default_dispatcher if dispatcher is None else dispatcher
  1282. self._request_timeout = float(request_timeout)
  1283. self._dispatch_condition = threading.Condition(threading.RLock())
  1284. self._lifecycle_state = "open"
  1285. self._dispatch_inflight = 0
  1286. self._cleanup_timeout = min(self._request_timeout, 5.0)
  1287. self._cleanup_condition = threading.Condition(threading.RLock())
  1288. self._cleanup_queue: deque[tuple[str, int]] = deque()
  1289. self._cleanup_pending: set[str] = set()
  1290. self._cleanup_claimed: set[str] = set()
  1291. self._cleanup_active: str | None = None
  1292. self._adoption_generation = 0
  1293. self._cleanup_closed = False
  1294. self._max_cleanup_queue = max_late_responses
  1295. self.transport = GroupGatewayTransport(
  1296. on_event
  1297. if on_event is not None
  1298. else (lambda _runtime_id, _event_type, _payload: None),
  1299. max_control_frames=max_control_frames,
  1300. late_response_ttl=late_response_ttl,
  1301. max_late_responses=max_late_responses,
  1302. )
  1303. self._cleanup_worker = threading.Thread(
  1304. target=self._late_cleanup_worker,
  1305. name="yaoyao-group-gateway-late-cleanup",
  1306. daemon=True,
  1307. )
  1308. self._cleanup_worker.start()
  1309. def _call_dispatcher(
  1310. self,
  1311. request: dict[str, Any],
  1312. transport: GroupGatewayTransport,
  1313. ) -> dict[str, Any] | None:
  1314. with self._dispatch_condition:
  1315. if self._lifecycle_state != "open":
  1316. raise GatewayTransportClosed("Gateway adapter is shutting down")
  1317. self._dispatch_inflight += 1
  1318. try:
  1319. return self._dispatcher(request, transport)
  1320. finally:
  1321. with self._dispatch_condition:
  1322. self._dispatch_inflight -= 1
  1323. self._dispatch_condition.notify_all()
  1324. def request(
  1325. self,
  1326. method: str,
  1327. params: dict[str, Any],
  1328. timeout: float | None = None,
  1329. ) -> Any:
  1330. method = _nonempty_string(method, "method")
  1331. if not isinstance(params, dict):
  1332. raise GatewayValidationError("params must be an object")
  1333. effective_timeout = self._request_timeout if timeout is None else timeout
  1334. if (
  1335. isinstance(effective_timeout, bool)
  1336. or not isinstance(effective_timeout, (int, float))
  1337. or not math.isfinite(effective_timeout)
  1338. or effective_timeout <= 0
  1339. ):
  1340. raise GatewayValidationError("timeout must be positive")
  1341. try:
  1342. request_params = copy.deepcopy(params)
  1343. except Exception: # noqa: BLE001 - reject caller objects at the boundary
  1344. raise GatewayValidationError(
  1345. "params must contain copyable values"
  1346. ) from None
  1347. request_id = str(uuid.uuid4())
  1348. request = {
  1349. "jsonrpc": "2.0",
  1350. "id": request_id,
  1351. "method": method,
  1352. "params": request_params,
  1353. }
  1354. late_context = self._late_session_context(request_id, method, request_params)
  1355. late_callback = (
  1356. partial(self._handle_late_session_response, late_context)
  1357. if late_context is not None
  1358. else None
  1359. )
  1360. waiter = self.transport.register_waiter(
  1361. request_id,
  1362. method=method,
  1363. on_late_response=late_callback,
  1364. )
  1365. try:
  1366. inline_response = self._call_dispatcher(request, self.transport)
  1367. except GatewayTransportClosed:
  1368. self.transport.cancel_waiter(waiter)
  1369. raise
  1370. except Exception as exc:
  1371. self.transport.cancel_waiter(waiter)
  1372. raise GatewayDispatchError(method) from exc
  1373. if inline_response is not None:
  1374. if not isinstance(inline_response, dict):
  1375. self.transport.cancel_waiter(waiter)
  1376. raise GatewayProtocolError(
  1377. method, "dispatcher returned a non-object response"
  1378. )
  1379. if (
  1380. inline_response.get("jsonrpc") != "2.0"
  1381. or inline_response.get("id") != request_id
  1382. or "method" in inline_response
  1383. ):
  1384. self.transport.cancel_waiter(waiter)
  1385. raise GatewayProtocolError(
  1386. method, "dispatcher returned an unrelated or invalid response"
  1387. )
  1388. accepted = self.transport.write(inline_response)
  1389. if not accepted:
  1390. # close() has already completed the registered waiter.
  1391. return self._parse_response(
  1392. self.transport.wait_for_response(
  1393. waiter,
  1394. timeout=float(effective_timeout),
  1395. method=method,
  1396. ),
  1397. request_id,
  1398. method,
  1399. )
  1400. response = self.transport.wait_for_response(
  1401. waiter,
  1402. timeout=float(effective_timeout),
  1403. method=method,
  1404. )
  1405. return self._parse_response(response, request_id, method)
  1406. @staticmethod
  1407. def _parse_response(
  1408. response: dict[str, Any],
  1409. request_id: str,
  1410. method: str,
  1411. ) -> Any:
  1412. if response.get("jsonrpc") != "2.0" or response.get("id") != request_id:
  1413. raise GatewayProtocolError(method, "invalid JSON-RPC response envelope")
  1414. has_result = "result" in response
  1415. has_error = "error" in response
  1416. if has_result == has_error:
  1417. raise GatewayProtocolError(
  1418. method, "response must contain exactly one of result or error"
  1419. )
  1420. if has_error:
  1421. error = response["error"]
  1422. if not isinstance(error, dict):
  1423. raise GatewayProtocolError(method, "invalid JSON-RPC error")
  1424. code = error.get("code")
  1425. message = error.get("message")
  1426. if (
  1427. isinstance(code, bool)
  1428. or not isinstance(code, int)
  1429. or not isinstance(message, str)
  1430. ):
  1431. raise GatewayProtocolError(method, "invalid JSON-RPC error")
  1432. raise GatewayRPCError(method, code, message, error.get("data"))
  1433. return response["result"]
  1434. @staticmethod
  1435. def _result_object(result: Any, method: str) -> dict[str, Any]:
  1436. if not isinstance(result, dict):
  1437. raise GatewayProtocolError(method, "result must be an object")
  1438. return result
  1439. def _late_session_context(
  1440. self,
  1441. request_id: str,
  1442. method: str,
  1443. params: dict[str, Any],
  1444. ) -> _LateSessionContext | None:
  1445. if method not in {"session.create", "session.resume"}:
  1446. return None
  1447. profile = params.get("profile")
  1448. if not isinstance(profile, str) or not profile:
  1449. return None
  1450. requested_stored_id: str | None = None
  1451. if method == "session.resume":
  1452. requested_stored_id = params.get("session_id")
  1453. if not isinstance(requested_stored_id, str) or not requested_stored_id:
  1454. return None
  1455. with self._cleanup_condition:
  1456. adoption_generation = self._adoption_generation
  1457. return _LateSessionContext(
  1458. request_id=request_id,
  1459. method=method,
  1460. profile=profile,
  1461. requested_stored_id=requested_stored_id,
  1462. adoption_generation=adoption_generation,
  1463. )
  1464. def _handle_late_session_response(
  1465. self,
  1466. context: _LateSessionContext,
  1467. tombstone_method: str,
  1468. response: dict[str, Any],
  1469. ) -> None:
  1470. if tombstone_method != context.method:
  1471. return
  1472. # session.resume may legitimately reuse a runtime that another request or
  1473. # device already owns. Its response has no core-authenticated "created"
  1474. # bit, so a timeout can never prove that the returned runtime is an orphan.
  1475. if context.method == "session.resume":
  1476. return
  1477. if (
  1478. response.get("jsonrpc") != "2.0"
  1479. or response.get("id") != context.request_id
  1480. or "method" in response
  1481. or ("result" in response) == ("error" in response)
  1482. ):
  1483. return
  1484. result = response.get("result")
  1485. if not isinstance(result, dict):
  1486. return
  1487. runtime_id = result.get("session_id")
  1488. if not isinstance(runtime_id, str) or not runtime_id:
  1489. return
  1490. info = result.get("info")
  1491. if not isinstance(info, dict):
  1492. return
  1493. profile_name = info.get("profile_name")
  1494. if profile_name != context.profile:
  1495. return
  1496. stored_id = result.get("stored_session_id")
  1497. if not isinstance(stored_id, str) or not stored_id:
  1498. return
  1499. running = result.get("running", False)
  1500. # A running runtime may already be serving another device. Cleanup is
  1501. # deliberately skipped even though that can leave a timed-out create
  1502. # behind; leaking an uncertain orphan is safer than killing active work.
  1503. if not isinstance(running, bool) or running:
  1504. return
  1505. self._schedule_late_cleanup(runtime_id, context.adoption_generation)
  1506. def _schedule_late_cleanup(
  1507. self, runtime_id: str, adoption_generation: int
  1508. ) -> None:
  1509. with self._cleanup_condition:
  1510. if (
  1511. self._cleanup_closed
  1512. or adoption_generation != self._adoption_generation
  1513. or runtime_id in self._cleanup_claimed
  1514. or runtime_id in self._cleanup_pending
  1515. or len(self._cleanup_claimed) >= self._max_cleanup_queue
  1516. ):
  1517. return
  1518. while (
  1519. len(self._cleanup_queue) >= self._max_cleanup_queue
  1520. and not self._cleanup_closed
  1521. ):
  1522. self._cleanup_condition.wait()
  1523. if (
  1524. self._cleanup_closed
  1525. or adoption_generation != self._adoption_generation
  1526. or runtime_id in self._cleanup_claimed
  1527. or runtime_id in self._cleanup_pending
  1528. or len(self._cleanup_claimed) >= self._max_cleanup_queue
  1529. ):
  1530. return
  1531. self._cleanup_pending.add(runtime_id)
  1532. self._cleanup_queue.append((runtime_id, adoption_generation))
  1533. self._cleanup_condition.notify_all()
  1534. def _adopt_runtime(self, runtime_id: str, method: str) -> None:
  1535. """Atomically adopt a runtime or reject one already claimed for cleanup."""
  1536. with self._cleanup_condition:
  1537. if self._cleanup_closed:
  1538. raise GatewayTransportClosed("Gateway adapter is shutting down")
  1539. if runtime_id in self._cleanup_claimed:
  1540. raise GatewayProtocolError(
  1541. method, "runtime cleanup won the adoption race"
  1542. )
  1543. self._adoption_generation += 1
  1544. queued_runtime_ids = {
  1545. queued_runtime_id for queued_runtime_id, _generation in self._cleanup_queue
  1546. }
  1547. self._cleanup_queue.clear()
  1548. self._cleanup_pending.difference_update(queued_runtime_ids)
  1549. self._cleanup_condition.notify_all()
  1550. def _late_cleanup_worker(self) -> None:
  1551. while True:
  1552. with self._cleanup_condition:
  1553. while not self._cleanup_queue and not self._cleanup_closed:
  1554. self._cleanup_condition.wait()
  1555. if self._cleanup_closed:
  1556. return
  1557. runtime_id, adoption_generation = self._cleanup_queue.popleft()
  1558. if (
  1559. adoption_generation != self._adoption_generation
  1560. or runtime_id in self._cleanup_claimed
  1561. or len(self._cleanup_claimed) >= self._max_cleanup_queue
  1562. ):
  1563. self._cleanup_pending.discard(runtime_id)
  1564. self._cleanup_condition.notify_all()
  1565. continue
  1566. # Claimed IDs are terminal for this adapter's lifetime. A core
  1567. # dispatcher can remain blocked for an arbitrary duration, so
  1568. # expiring this proof would allow an ABA adoption of a runtime
  1569. # already handed to session.close. The hard capacity above
  1570. # bounds memory; once full, later orphans are leaked safely.
  1571. self._cleanup_claimed.add(runtime_id)
  1572. self._cleanup_active = runtime_id
  1573. self._cleanup_condition.notify_all()
  1574. try:
  1575. self._best_effort_close_late_session(runtime_id)
  1576. finally:
  1577. with self._cleanup_condition:
  1578. self._cleanup_active = None
  1579. self._cleanup_pending.discard(runtime_id)
  1580. self._cleanup_condition.notify_all()
  1581. def _best_effort_close_late_session(self, runtime_id: str) -> None:
  1582. method = "session.close"
  1583. request_id = str(uuid.uuid4())
  1584. cleanup_transport = GroupGatewayTransport(
  1585. lambda _runtime_id, _event_type, _payload: None,
  1586. max_control_frames=1,
  1587. late_response_ttl=max(self._cleanup_timeout, 0.001),
  1588. max_late_responses=1,
  1589. )
  1590. waiter = cleanup_transport.register_waiter(request_id, method=method)
  1591. request = {
  1592. "jsonrpc": "2.0",
  1593. "id": request_id,
  1594. "method": method,
  1595. "params": {"session_id": runtime_id},
  1596. }
  1597. try:
  1598. inline_response = self._call_dispatcher(request, cleanup_transport)
  1599. if inline_response is not None:
  1600. if (
  1601. not isinstance(inline_response, dict)
  1602. or inline_response.get("jsonrpc") != "2.0"
  1603. or inline_response.get("id") != request_id
  1604. or "method" in inline_response
  1605. ):
  1606. raise GatewayProtocolError(
  1607. method, "cleanup dispatcher returned an invalid response"
  1608. )
  1609. cleanup_transport.write(inline_response)
  1610. response = cleanup_transport.wait_for_response(
  1611. waiter,
  1612. timeout=self._cleanup_timeout,
  1613. method=method,
  1614. )
  1615. result = self._result_object(
  1616. self._parse_response(response, request_id, method), method
  1617. )
  1618. _response_bool(result, "closed", method)
  1619. except Exception: # noqa: BLE001 - cleanup is deliberately best-effort
  1620. logger.warning(
  1621. "Best-effort close of a late YaoYao group session failed",
  1622. exc_info=True,
  1623. )
  1624. finally:
  1625. cleanup_transport.close()
  1626. @staticmethod
  1627. def _cwd(value: str) -> str:
  1628. if not isinstance(value, str):
  1629. raise GatewayValidationError("cwd must be a string")
  1630. if not value.strip():
  1631. return ""
  1632. path = Path(value)
  1633. if not path.is_absolute():
  1634. raise GatewayValidationError(
  1635. "cwd must be empty or an existing absolute directory"
  1636. )
  1637. try:
  1638. resolved = path.resolve(strict=True)
  1639. is_directory = resolved.is_dir()
  1640. except (OSError, RuntimeError, ValueError) as exc:
  1641. raise GatewayValidationError(
  1642. "cwd must be empty or an existing absolute directory"
  1643. ) from exc
  1644. if not is_directory:
  1645. raise GatewayValidationError(
  1646. "cwd must be empty or an existing absolute directory"
  1647. )
  1648. return value
  1649. def create_session(
  1650. self,
  1651. profile: str,
  1652. title: str,
  1653. cwd: str,
  1654. seed_messages: list[dict[str, Any]],
  1655. configuration: dict[str, Any] | None = None,
  1656. ) -> SessionIdentity:
  1657. method = "session.create"
  1658. profile = _nonempty_string(profile, "profile")
  1659. title = _nonempty_string(title, "title")
  1660. if not isinstance(seed_messages, list) or not all(
  1661. isinstance(message, dict) for message in seed_messages
  1662. ):
  1663. raise GatewayValidationError("seed_messages must be a list of objects")
  1664. parameters: dict[str, Any] = {
  1665. "profile": profile,
  1666. "title": title,
  1667. "cwd": self._cwd(cwd),
  1668. "messages": seed_messages,
  1669. "source": "ios_group",
  1670. "close_on_disconnect": False,
  1671. }
  1672. for key in ("model", "provider", "reasoning_effort", "fast"):
  1673. if configuration is not None and configuration.get(key) is not None:
  1674. parameters[key] = configuration[key]
  1675. result = self._result_object(
  1676. self.request(
  1677. method,
  1678. parameters,
  1679. ),
  1680. method,
  1681. )
  1682. runtime_id = _response_string(result, "session_id", method)
  1683. stored_id = _response_string(result, "stored_session_id", method)
  1684. running = _response_bool(result, "running", method, default=False)
  1685. info = result.get("info")
  1686. if not isinstance(info, dict):
  1687. raise GatewayProtocolError(method, "missing or invalid info.profile_name")
  1688. response_profile = info.get("profile_name")
  1689. if not isinstance(response_profile, str) or not response_profile:
  1690. raise GatewayProtocolError(method, "missing or invalid info.profile_name")
  1691. if response_profile != profile:
  1692. raise GatewayProtocolError(method, "profile does not match the request")
  1693. self._adopt_runtime(runtime_id, method)
  1694. return SessionIdentity(stored_id, runtime_id, running)
  1695. def resume_session(self, profile: str, stored_id: str) -> SessionIdentity:
  1696. method = "session.resume"
  1697. profile = _nonempty_string(profile, "profile")
  1698. stored_id = _nonempty_string(stored_id, "stored_id")
  1699. result = self._result_object(
  1700. self.request(
  1701. method,
  1702. {
  1703. "profile": profile,
  1704. "session_id": stored_id,
  1705. "omit_messages": True,
  1706. "source": "ios_group",
  1707. "close_on_disconnect": False,
  1708. },
  1709. ),
  1710. method,
  1711. )
  1712. runtime_id = _response_string(result, "session_id", method)
  1713. session_key = result.get("session_key")
  1714. resumed = result.get("resumed")
  1715. if session_key is not None and (
  1716. not isinstance(session_key, str) or not session_key
  1717. ):
  1718. raise GatewayProtocolError(method, "missing or invalid session_key")
  1719. if resumed is not None and (not isinstance(resumed, str) or not resumed):
  1720. raise GatewayProtocolError(method, "missing or invalid resumed")
  1721. if session_key is not None and resumed is not None and session_key != resumed:
  1722. raise GatewayProtocolError(method, "ambiguous stored session identity")
  1723. current_stored_id = session_key or resumed
  1724. if current_stored_id is None:
  1725. raise GatewayProtocolError(method, "missing session_key or resumed")
  1726. running = _response_bool(result, "running", method)
  1727. info = result.get("info")
  1728. if not isinstance(info, dict):
  1729. raise GatewayProtocolError(method, "missing or invalid info.profile_name")
  1730. response_profile = info.get("profile_name")
  1731. if not isinstance(response_profile, str) or not response_profile:
  1732. raise GatewayProtocolError(method, "missing or invalid info.profile_name")
  1733. if response_profile != profile:
  1734. raise GatewayProtocolError(method, "profile does not match the request")
  1735. self._adopt_runtime(runtime_id, method)
  1736. return SessionIdentity(current_stored_id, runtime_id, running)
  1737. def submit_prompt(self, runtime_id: str, text: str) -> None:
  1738. method = "prompt.submit"
  1739. result = self._result_object(
  1740. self.request(
  1741. method,
  1742. {
  1743. "session_id": _nonempty_string(runtime_id, "runtime_id"),
  1744. "text": _nonempty_string(text, "text"),
  1745. },
  1746. ),
  1747. method,
  1748. )
  1749. if result.get("status") != "streaming":
  1750. raise GatewayProtocolError(method, "expected streaming status")
  1751. def interrupt_session(self, runtime_id: str) -> None:
  1752. method = "session.interrupt"
  1753. result = self._result_object(
  1754. self.request(
  1755. method, {"session_id": _nonempty_string(runtime_id, "runtime_id")}
  1756. ),
  1757. method,
  1758. )
  1759. if result.get("status") != "interrupted":
  1760. raise GatewayProtocolError(method, "expected interrupted status")
  1761. def interrupt(self, runtime_id: str) -> None:
  1762. self.interrupt_session(runtime_id)
  1763. def close_session(self, runtime_id: str) -> bool:
  1764. method = "session.close"
  1765. result = self._result_object(
  1766. self.request(
  1767. method, {"session_id": _nonempty_string(runtime_id, "runtime_id")}
  1768. ),
  1769. method,
  1770. )
  1771. return _response_bool(result, "closed", method)
  1772. def respond_approval(self, runtime_id: str, choice: str) -> int:
  1773. method = "approval.respond"
  1774. runtime_id = _nonempty_string(runtime_id, "runtime_id")
  1775. choice = _nonempty_string(choice, "choice")
  1776. if choice not in _APPROVAL_CHOICES:
  1777. raise GatewayValidationError(
  1778. "choice must be once, session, always, or deny"
  1779. )
  1780. result = self._result_object(
  1781. self.request(
  1782. method,
  1783. {"session_id": runtime_id, "choice": choice, "all": False},
  1784. ),
  1785. method,
  1786. )
  1787. resolved = result.get("resolved")
  1788. if isinstance(resolved, bool) or not isinstance(resolved, int) or resolved < 0:
  1789. raise GatewayProtocolError(method, "missing or invalid resolved count")
  1790. return resolved
  1791. def respond_clarification(self, request_id: str, answer: str) -> str:
  1792. method = "clarify.respond"
  1793. result = self._result_object(
  1794. self.request(
  1795. method,
  1796. {
  1797. "request_id": _nonempty_string(request_id, "request_id"),
  1798. "answer": _nonempty_string(answer, "answer"),
  1799. },
  1800. ),
  1801. method,
  1802. )
  1803. status = result.get("status")
  1804. if status not in {"ok", "expired"}:
  1805. raise GatewayProtocolError(method, "expected ok or expired status")
  1806. return status
  1807. def shutdown(self) -> None:
  1808. with self._dispatch_condition:
  1809. if self._lifecycle_state == "closed":
  1810. return
  1811. if self._lifecycle_state == "closing":
  1812. if threading.current_thread() in {
  1813. self.transport._worker,
  1814. self._cleanup_worker,
  1815. }:
  1816. return
  1817. while self._lifecycle_state != "closed":
  1818. self._dispatch_condition.wait()
  1819. return
  1820. self._lifecycle_state = "closing"
  1821. with self._cleanup_condition:
  1822. if not self._cleanup_closed:
  1823. self._cleanup_closed = True
  1824. self._cleanup_queue.clear()
  1825. if self._cleanup_active is None:
  1826. self._cleanup_pending.clear()
  1827. else:
  1828. self._cleanup_pending.intersection_update(
  1829. {self._cleanup_active}
  1830. )
  1831. self._cleanup_condition.notify_all()
  1832. self.transport.close()
  1833. with self._dispatch_condition:
  1834. while self._dispatch_inflight:
  1835. self._dispatch_condition.wait()
  1836. if threading.current_thread() is not self._cleanup_worker:
  1837. self._cleanup_worker.join()
  1838. with self._cleanup_condition:
  1839. self._cleanup_claimed.clear()
  1840. self._cleanup_active = None
  1841. with self._dispatch_condition:
  1842. self._lifecycle_state = "closed"
  1843. self._dispatch_condition.notify_all()
  1844. def close(self, runtime_id: str) -> bool:
  1845. """Close one core session; ``shutdown`` owns transport lifecycle."""
  1846. return self.close_session(runtime_id)
  1847. # The shorter name is the stable design-level spelling used by the orchestrator.
  1848. GroupTransport = GroupGatewayTransport
  1849. __all__ = [
  1850. "GatewayDispatchError",
  1851. "GatewayError",
  1852. "GatewayProtocolError",
  1853. "GatewayRPCError",
  1854. "GatewayTimeoutError",
  1855. "GatewayTransportClosed",
  1856. "GatewayValidationError",
  1857. "GroupGatewayAdapter",
  1858. "GroupGatewayTransport",
  1859. "GroupTransport",
  1860. "MAX_CONTROL_PAYLOAD_BYTES",
  1861. "MAX_DELTA_BUFFER_BYTES",
  1862. "MAX_SAFE_JSON_INTEGER",
  1863. "MAX_TERMINAL_TEXT_BYTES",
  1864. "SessionIdentity",
  1865. ]