client.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. from __future__ import annotations
  2. import json
  3. import os
  4. import queue
  5. import subprocess
  6. import threading
  7. import time
  8. import uuid
  9. from collections import deque
  10. from dataclasses import dataclass
  11. from pathlib import Path
  12. from typing import Callable, Literal, TypeAlias, TypeVar
  13. from pydantic import BaseModel
  14. from .errors import JsonRpcError, TransportClosedError
  15. from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification
  16. ModelT = TypeVar("ModelT", bound=BaseModel)
  17. NotificationFilter: TypeAlias = Callable[[Notification], bool]
  18. @dataclass(slots=True)
  19. class HarnessConfig:
  20. """Configuration for launching the local DeepSeek Harness SDK runtime."""
  21. runtime_bin: str | None = None
  22. bridge_bin: str | None = None
  23. launch_args_override: tuple[str, ...] | None = None
  24. cwd: str | None = None
  25. env: dict[str, str] | None = None
  26. request_timeout_seconds: float | None = None
  27. shutdown_timeout_seconds: float | None = 1.0
  28. class HarnessClient:
  29. """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
  30. def __init__(self, config: HarnessConfig | None = None) -> None:
  31. self.config = config or HarnessConfig()
  32. self._proc: subprocess.Popen[str] | None = None
  33. self._lock = threading.Lock()
  34. self._write_lock = threading.Lock()
  35. self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {}
  36. self._notifications: queue.Queue[Notification | BaseException] = queue.Queue()
  37. self._notification_subscribers: dict[
  38. str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None]
  39. ] = {}
  40. self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue()
  41. self._stderr_lines: deque[str] = deque(maxlen=400)
  42. self._reader_thread: threading.Thread | None = None
  43. self._stderr_thread: threading.Thread | None = None
  44. def __enter__(self) -> "HarnessClient":
  45. self.start()
  46. return self
  47. def __exit__(self, _exc_type, _exc, _tb) -> None:
  48. self.close()
  49. def start(self) -> None:
  50. if self._proc is not None:
  51. return
  52. args = list(self.config.launch_args_override or self._default_launch_args())
  53. env = os.environ.copy()
  54. if self.config.env:
  55. env.update(self.config.env)
  56. self._inject_bundled_default_config(env)
  57. self._proc = subprocess.Popen(
  58. args,
  59. stdin=subprocess.PIPE,
  60. stdout=subprocess.PIPE,
  61. stderr=subprocess.PIPE,
  62. text=True,
  63. encoding="utf-8",
  64. cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()),
  65. env=env,
  66. bufsize=1,
  67. )
  68. self._start_reader_thread()
  69. self._start_stderr_thread()
  70. def close(self) -> None:
  71. proc = self._proc
  72. if proc is None:
  73. return
  74. try:
  75. self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
  76. except Exception as exc:
  77. self._stderr_lines.append(f"shutdown request failed: {exc}")
  78. if proc.stdin:
  79. try:
  80. proc.stdin.close()
  81. except Exception as exc:
  82. self._stderr_lines.append(f"stdin close failed: {exc}")
  83. if proc.poll() is None:
  84. try:
  85. proc.terminate()
  86. except ProcessLookupError:
  87. pass
  88. try:
  89. proc.wait(timeout=self.config.shutdown_timeout_seconds)
  90. except subprocess.TimeoutExpired:
  91. proc.kill()
  92. proc.wait()
  93. self._proc = None
  94. self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed"))
  95. if self._reader_thread and self._reader_thread.is_alive():
  96. self._reader_thread.join(timeout=0.5)
  97. if self._stderr_thread and self._stderr_thread.is_alive():
  98. self._stderr_thread.join(timeout=0.5)
  99. def initialize(
  100. self,
  101. *,
  102. cwd: str,
  103. model: str,
  104. ) -> InitializeResponse:
  105. payload: JsonObject = {
  106. "cwd": str(Path(cwd).resolve()),
  107. "model": model,
  108. }
  109. try:
  110. return self.request("initialize", payload, response_model=InitializeResponse)
  111. except BaseException:
  112. self.close()
  113. raise
  114. def session_prompt(
  115. self,
  116. session_id: str,
  117. content_blocks: list[JsonObject],
  118. *,
  119. on_notification: Callable[[Notification], None] | None = None,
  120. notification_subscription: "NotificationSubscription | None" = None,
  121. ) -> None:
  122. payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks}
  123. self.request(
  124. "session/prompt",
  125. payload,
  126. response_model=_SessionPromptResponse,
  127. on_notification=on_notification,
  128. notification_filter=_notification_belongs_to_session(session_id),
  129. notification_subscription=notification_subscription,
  130. )
  131. def request(
  132. self,
  133. method: str,
  134. params: JsonObject | None,
  135. *,
  136. response_model: type[ModelT],
  137. timeout_seconds: float | None = None,
  138. on_notification: Callable[[Notification], None] | None = None,
  139. notification_filter: NotificationFilter | None = None,
  140. notification_subscription: "NotificationSubscription | None" = None,
  141. ) -> ModelT:
  142. result = self._request_raw(
  143. method,
  144. params,
  145. timeout_seconds=timeout_seconds,
  146. on_notification=on_notification,
  147. notification_filter=notification_filter,
  148. notification_subscription=notification_subscription,
  149. )
  150. if not isinstance(result, dict):
  151. raise TypeError(f"{method} response must be a JSON object")
  152. return response_model.model_validate(result)
  153. def notify(self, method: str, params: JsonObject | None = None) -> None:
  154. message: JsonObject = {"jsonrpc": "2.0", "method": method}
  155. if params is not None:
  156. message["params"] = params
  157. self._write_message(message)
  158. def next_notification(self) -> Notification:
  159. item = self._notifications.get()
  160. if isinstance(item, BaseException):
  161. raise item
  162. return item
  163. def subscribe_notifications(
  164. self,
  165. notification_filter: NotificationFilter | None = None,
  166. ) -> "NotificationSubscription":
  167. subscription_id = str(uuid.uuid4())
  168. notifications: queue.Queue[Notification | BaseException] = queue.Queue()
  169. with self._lock:
  170. self._notification_subscribers[subscription_id] = (notifications, notification_filter)
  171. return NotificationSubscription(self, subscription_id, notifications)
  172. def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
  173. return self.subscribe_notifications(_notification_belongs_to_session(session_id))
  174. def next_request(self) -> IncomingRequest:
  175. item = self._requests.get()
  176. if isinstance(item, BaseException):
  177. raise item
  178. return item
  179. def respond(self, request_id: str | int, result: JsonValue) -> None:
  180. self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
  181. def respond_error(
  182. self,
  183. request_id: str | int,
  184. *,
  185. code: int,
  186. message: str,
  187. data: JsonValue | None = None,
  188. ) -> None:
  189. error: JsonObject = {"code": code, "message": message}
  190. if data is not None:
  191. error["data"] = data
  192. self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error})
  193. def _request_raw(
  194. self,
  195. method: str,
  196. params: JsonObject | None = None,
  197. *,
  198. timeout_seconds: float | None = None,
  199. on_notification: Callable[[Notification], None] | None = None,
  200. notification_filter: NotificationFilter | None = None,
  201. notification_subscription: "NotificationSubscription | None" = None,
  202. ) -> JsonValue:
  203. request_id = str(uuid.uuid4())
  204. waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
  205. temp_subscription: NotificationSubscription | None = None
  206. subscription = notification_subscription
  207. with self._lock:
  208. self._responses[request_id] = waiter
  209. if on_notification is not None and subscription is None:
  210. temp_subscription = self.subscribe_notifications(notification_filter)
  211. subscription = temp_subscription
  212. try:
  213. message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
  214. if params is not None:
  215. message["params"] = params
  216. self._write_message(message)
  217. except BaseException:
  218. with self._lock:
  219. self._responses.pop(request_id, None)
  220. if temp_subscription is not None:
  221. temp_subscription.close()
  222. raise
  223. timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
  224. deadline = None if timeout is None else time.monotonic() + timeout
  225. try:
  226. while True:
  227. if on_notification is not None and subscription is not None:
  228. subscription.drain(on_notification)
  229. wait_timeout = None
  230. if on_notification is not None:
  231. wait_timeout = 0.05
  232. if deadline is not None:
  233. remaining = deadline - time.monotonic()
  234. if remaining <= 0:
  235. with self._lock:
  236. self._responses.pop(request_id, None)
  237. raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime")
  238. wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
  239. try:
  240. item = waiter.get(timeout=wait_timeout)
  241. if on_notification is not None and subscription is not None:
  242. subscription.drain(on_notification)
  243. break
  244. except queue.Empty:
  245. continue
  246. except BaseException:
  247. with self._lock:
  248. self._responses.pop(request_id, None)
  249. if temp_subscription is not None:
  250. temp_subscription.close()
  251. raise
  252. finally:
  253. if temp_subscription is not None:
  254. temp_subscription.close()
  255. if isinstance(item, BaseException):
  256. raise item
  257. return item
  258. def _write_message(self, message: JsonObject) -> None:
  259. proc = self._proc
  260. if proc is None or proc.stdin is None:
  261. raise TransportClosedError("DeepSeek Harness runtime is not running")
  262. try:
  263. payload = json.dumps(message, separators=(",", ":")) + "\n"
  264. with self._write_lock:
  265. proc.stdin.write(payload)
  266. proc.stdin.flush()
  267. except Exception as exc:
  268. raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc
  269. def _start_reader_thread(self) -> None:
  270. self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True)
  271. self._reader_thread.start()
  272. def _start_stderr_thread(self) -> None:
  273. self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True)
  274. self._stderr_thread.start()
  275. def _reader_loop(self) -> None:
  276. proc = self._proc
  277. if proc is None or proc.stdout is None:
  278. return
  279. try:
  280. for line in proc.stdout:
  281. if not line.strip():
  282. continue
  283. try:
  284. message = json.loads(line)
  285. except json.JSONDecodeError:
  286. continue
  287. self._handle_message(message)
  288. except BaseException as exc:
  289. self._fail_waiters(exc)
  290. finally:
  291. self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed"))
  292. def _stderr_loop(self) -> None:
  293. proc = self._proc
  294. if proc is None or proc.stderr is None:
  295. return
  296. for line in proc.stderr:
  297. self._stderr_lines.append(line.rstrip())
  298. def _handle_message(self, message: object) -> None:
  299. if not isinstance(message, dict):
  300. return
  301. msg_id = message.get("id")
  302. method = message.get("method")
  303. if isinstance(msg_id, (str, int)) and isinstance(method, str):
  304. params = message.get("params")
  305. self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}))
  306. return
  307. if isinstance(msg_id, (str, int)):
  308. with self._lock:
  309. waiter = self._responses.pop(str(msg_id), None)
  310. if waiter is None:
  311. return
  312. if isinstance(message.get("error"), dict):
  313. err = message["error"]
  314. waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data")))
  315. else:
  316. waiter.put(message.get("result"))
  317. return
  318. if isinstance(method, str):
  319. params = message.get("params")
  320. notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
  321. with self._lock:
  322. subscribers = list(self._notification_subscribers.items())
  323. delivered = False
  324. for subscription_id, (subscriber, predicate) in subscribers:
  325. try:
  326. matches = predicate is None or predicate(notification)
  327. except BaseException as exc:
  328. with self._lock:
  329. current = self._notification_subscribers.get(subscription_id)
  330. if current is not None and current[0] is subscriber:
  331. self._notification_subscribers.pop(subscription_id, None)
  332. subscriber.put(exc)
  333. continue
  334. if matches:
  335. subscriber.put(notification)
  336. delivered = True
  337. if not delivered:
  338. self._notifications.put(notification)
  339. def _fail_waiters(self, exc: BaseException) -> None:
  340. with self._lock:
  341. waiters = list(self._responses.values())
  342. self._responses.clear()
  343. subscribers = list(self._notification_subscribers.values())
  344. self._notification_subscribers.clear()
  345. for waiter in waiters:
  346. waiter.put(exc)
  347. for subscriber, _predicate in subscribers:
  348. subscriber.put(exc)
  349. self._notifications.put(exc)
  350. self._requests.put(exc)
  351. def _runtime_closed_error(self, reason: str) -> TransportClosedError:
  352. proc = self._proc
  353. if (
  354. proc is not None
  355. and proc.poll() is not None
  356. and self._stderr_thread is not None
  357. and self._stderr_thread.is_alive()
  358. and threading.current_thread() is not self._stderr_thread
  359. ):
  360. self._stderr_thread.join(timeout=0.1)
  361. parts = [reason]
  362. if proc is not None:
  363. exit_code = proc.poll()
  364. if exit_code is not None:
  365. parts.append(f"exit code: {exit_code}")
  366. if self._stderr_lines:
  367. parts.append("stderr tail:\n" + "\n".join(self._stderr_lines))
  368. return TransportClosedError("\n".join(parts))
  369. def _default_launch_args(self) -> tuple[str, ...]:
  370. if self.config.runtime_bin is not None:
  371. return (self.config.runtime_bin,)
  372. if self.config.bridge_bin is not None:
  373. return (self.config.bridge_bin,)
  374. try:
  375. from deepseek_harness_runtime import resolve_bundled_launch_args
  376. except ImportError as exc:
  377. raise FileNotFoundError(
  378. "Unable to locate the bundled DeepSeek Harness SDK runtime. "
  379. "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin."
  380. ) from exc
  381. return resolve_bundled_launch_args()
  382. def _inject_bundled_default_config(self, env: dict[str, str]) -> None:
  383. """Restore the zero-config experience over the config-mandatory bundled runtime.
  384. The bundled runtime (single-file exe or the dev-only node closure)
  385. always demands an explicit config. When the launch resolves to the
  386. bundled runtime (no ``runtime_bin`` / ``bridge_bin`` /
  387. ``launch_args_override``) and the merged subprocess environment has no
  388. non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty
  389. value as absent, so this does too — inject the runtime package's
  390. checked-in default cordis.yml. With an explicit runtime or config
  391. channel the client stays out of the way.
  392. """
  393. uses_bundled_runtime = (
  394. self.config.launch_args_override is None
  395. and self.config.runtime_bin is None
  396. and self.config.bridge_bin is None
  397. )
  398. if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"):
  399. return
  400. # Cannot fail: _default_launch_args() already imported the runtime
  401. # package on this (bundled) path, raising the actionable install
  402. # error when it is absent.
  403. from deepseek_harness_runtime import bundled_default_config_path
  404. env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path())
  405. def _unsubscribe_notifications(self, subscription_id: str) -> None:
  406. with self._lock:
  407. self._notification_subscribers.pop(subscription_id, None)
  408. class NotificationSubscription:
  409. def __init__(
  410. self,
  411. client: HarnessClient,
  412. subscription_id: str,
  413. notifications: queue.Queue[Notification | BaseException],
  414. ) -> None:
  415. self._client = client
  416. self._subscription_id = subscription_id
  417. self._notifications = notifications
  418. self._closed = False
  419. def __enter__(self) -> "NotificationSubscription":
  420. return self
  421. def __exit__(self, _exc_type, _exc, _tb) -> None:
  422. self.close()
  423. def close(self) -> None:
  424. if self._closed:
  425. return
  426. self._closed = True
  427. self._client._unsubscribe_notifications(self._subscription_id)
  428. def next(self) -> Notification:
  429. item = self._notifications.get()
  430. if isinstance(item, BaseException):
  431. raise item
  432. return item
  433. def drain(self, on_notification: Callable[[Notification], None]) -> None:
  434. while True:
  435. try:
  436. item = self._notifications.get_nowait()
  437. except queue.Empty:
  438. return
  439. if isinstance(item, BaseException):
  440. raise item
  441. on_notification(item)
  442. class _SessionPromptResponse(BaseModel):
  443. accepted: Literal[True]
  444. class _ShutdownResponse(BaseModel):
  445. pass
  446. def _int_or_none(value: object) -> int | None:
  447. return value if isinstance(value, int) else None
  448. def _notification_belongs_to_session(session_id: str) -> NotificationFilter:
  449. def belongs(notification: Notification) -> bool:
  450. payload = notification.payload
  451. return (
  452. payload.get("sessionId") == session_id
  453. or payload.get("parentSessionId") == session_id
  454. or payload.get("childSessionId") == session_id
  455. )
  456. return belongs