client.py 19 KB

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