client.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544
  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._session_parents: dict[str, str] = {}
  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. with self._lock:
  54. self._session_parents.clear()
  55. args = list(self.config.launch_args_override or self._default_launch_args())
  56. env = os.environ.copy()
  57. if self.config.env:
  58. env.update(self.config.env)
  59. self._inject_bundled_default_config(env)
  60. self._proc = subprocess.Popen(
  61. args,
  62. stdin=subprocess.PIPE,
  63. stdout=subprocess.PIPE,
  64. stderr=subprocess.PIPE,
  65. text=True,
  66. encoding="utf-8",
  67. cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()),
  68. env=env,
  69. bufsize=1,
  70. )
  71. self._start_reader_thread()
  72. self._start_stderr_thread()
  73. def close(self) -> None:
  74. proc = self._proc
  75. if proc is None:
  76. return
  77. try:
  78. self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
  79. except Exception as exc:
  80. self._stderr_lines.append(f"shutdown request failed: {exc}")
  81. if proc.stdin:
  82. try:
  83. proc.stdin.close()
  84. except Exception as exc:
  85. self._stderr_lines.append(f"stdin close failed: {exc}")
  86. if proc.poll() is None:
  87. try:
  88. proc.terminate()
  89. except ProcessLookupError:
  90. pass
  91. try:
  92. proc.wait(timeout=self.config.shutdown_timeout_seconds)
  93. except subprocess.TimeoutExpired:
  94. proc.kill()
  95. proc.wait()
  96. self._proc = None
  97. self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed"))
  98. if self._reader_thread and self._reader_thread.is_alive():
  99. self._reader_thread.join(timeout=0.5)
  100. if self._stderr_thread and self._stderr_thread.is_alive():
  101. self._stderr_thread.join(timeout=0.5)
  102. def initialize(
  103. self,
  104. *,
  105. cwd: str,
  106. provider: str,
  107. model: str,
  108. ) -> InitializeResponse:
  109. payload: JsonObject = {
  110. "cwd": str(Path(cwd).resolve()),
  111. "provider": provider,
  112. "model": model,
  113. }
  114. try:
  115. return self.request("initialize", payload, response_model=InitializeResponse)
  116. except BaseException:
  117. self.close()
  118. raise
  119. def session_prompt(
  120. self,
  121. session_id: str,
  122. content_blocks: list[JsonObject],
  123. *,
  124. on_notification: Callable[[Notification], None] | None = None,
  125. notification_subscription: "NotificationSubscription | None" = None,
  126. ) -> None:
  127. payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks}
  128. self.request(
  129. "session/prompt",
  130. payload,
  131. response_model=_SessionPromptResponse,
  132. on_notification=on_notification,
  133. notification_filter=self._notification_belongs_to_session_tree(session_id),
  134. notification_subscription=notification_subscription,
  135. )
  136. def request(
  137. self,
  138. method: str,
  139. params: JsonObject | None,
  140. *,
  141. response_model: type[ModelT],
  142. timeout_seconds: float | None = None,
  143. on_notification: Callable[[Notification], None] | None = None,
  144. notification_filter: NotificationFilter | None = None,
  145. notification_subscription: "NotificationSubscription | None" = None,
  146. ) -> ModelT:
  147. result = self._request_raw(
  148. method,
  149. params,
  150. timeout_seconds=timeout_seconds,
  151. on_notification=on_notification,
  152. notification_filter=notification_filter,
  153. notification_subscription=notification_subscription,
  154. )
  155. if not isinstance(result, dict):
  156. raise TypeError(f"{method} response must be a JSON object")
  157. return response_model.model_validate(result)
  158. def notify(self, method: str, params: JsonObject | None = None) -> None:
  159. message: JsonObject = {"jsonrpc": "2.0", "method": method}
  160. if params is not None:
  161. message["params"] = params
  162. self._write_message(message)
  163. def next_notification(self) -> Notification:
  164. item = self._notifications.get()
  165. if isinstance(item, BaseException):
  166. raise item
  167. return item
  168. def subscribe_notifications(
  169. self,
  170. notification_filter: NotificationFilter | None = None,
  171. ) -> "NotificationSubscription":
  172. subscription_id = str(uuid.uuid4())
  173. notifications: queue.Queue[Notification | BaseException] = queue.Queue()
  174. with self._lock:
  175. self._notification_subscribers[subscription_id] = (notifications, notification_filter)
  176. return NotificationSubscription(self, subscription_id, notifications)
  177. def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
  178. """Subscribe to a session and descendants discovered from subagent lifecycle edges."""
  179. return self.subscribe_notifications(self._notification_belongs_to_session_tree(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. self._record_session_relationship_locked(notification)
  329. subscribers = list(self._notification_subscribers.items())
  330. delivered = False
  331. for subscription_id, (subscriber, predicate) in subscribers:
  332. try:
  333. matches = predicate is None or predicate(notification)
  334. except BaseException as exc:
  335. with self._lock:
  336. current = self._notification_subscribers.get(subscription_id)
  337. if current is not None and current[0] is subscriber:
  338. self._notification_subscribers.pop(subscription_id, None)
  339. subscriber.put(exc)
  340. continue
  341. if matches:
  342. subscriber.put(notification)
  343. delivered = True
  344. if not delivered:
  345. self._notifications.put(notification)
  346. def _fail_waiters(self, exc: BaseException) -> None:
  347. with self._lock:
  348. waiters = list(self._responses.values())
  349. self._responses.clear()
  350. subscribers = list(self._notification_subscribers.values())
  351. self._notification_subscribers.clear()
  352. for waiter in waiters:
  353. waiter.put(exc)
  354. for subscriber, _predicate in subscribers:
  355. subscriber.put(exc)
  356. self._notifications.put(exc)
  357. self._requests.put(exc)
  358. def _runtime_closed_error(self, reason: str) -> TransportClosedError:
  359. proc = self._proc
  360. if (
  361. proc is not None
  362. and proc.poll() is not None
  363. and self._stderr_thread is not None
  364. and self._stderr_thread.is_alive()
  365. and threading.current_thread() is not self._stderr_thread
  366. ):
  367. self._stderr_thread.join(timeout=0.1)
  368. parts = [reason]
  369. if proc is not None:
  370. exit_code = proc.poll()
  371. if exit_code is not None:
  372. parts.append(f"exit code: {exit_code}")
  373. if self._stderr_lines:
  374. parts.append("stderr tail:\n" + "\n".join(self._stderr_lines))
  375. return TransportClosedError("\n".join(parts))
  376. def _default_launch_args(self) -> tuple[str, ...]:
  377. if self.config.runtime_bin is not None:
  378. return (self.config.runtime_bin,)
  379. if self.config.bridge_bin is not None:
  380. return (self.config.bridge_bin,)
  381. try:
  382. from deepseek_harness_runtime import resolve_bundled_launch_args
  383. except ImportError as exc:
  384. raise FileNotFoundError(
  385. "Unable to locate the bundled DeepSeek Harness SDK runtime. "
  386. "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin."
  387. ) from exc
  388. return resolve_bundled_launch_args()
  389. def _inject_bundled_default_config(self, env: dict[str, str]) -> None:
  390. """Inject the default config for a bundled launch with no non-empty config.
  391. Both bundled carriers require an explicit config. Explicit runtime,
  392. launch-argument, and config channels remain untouched.
  393. """
  394. uses_bundled_runtime = (
  395. self.config.launch_args_override is None
  396. and self.config.runtime_bin is None
  397. and self.config.bridge_bin is None
  398. )
  399. if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"):
  400. return
  401. # _default_launch_args already imported the package or raised its install error.
  402. from deepseek_harness_runtime import bundled_default_config_path
  403. env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path())
  404. def _unsubscribe_notifications(self, subscription_id: str) -> None:
  405. with self._lock:
  406. self._notification_subscribers.pop(subscription_id, None)
  407. def _record_session_relationship_locked(self, notification: Notification) -> None:
  408. if notification.method != "subagent.started":
  409. return
  410. parent_id = notification.payload.get("parentSessionId")
  411. child_id = notification.payload.get("childSessionId")
  412. if (
  413. isinstance(parent_id, str)
  414. and parent_id
  415. and isinstance(child_id, str)
  416. and child_id
  417. and parent_id != child_id
  418. ):
  419. self._session_parents[child_id] = parent_id
  420. def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter:
  421. def belongs(notification: Notification) -> bool:
  422. payload = notification.payload
  423. if notification.method in {"subagent.started", "subagent.finished"}:
  424. parent_id = payload.get("parentSessionId")
  425. if (
  426. isinstance(parent_id, str)
  427. and self._session_is_descendant_of(parent_id, session_id)
  428. ):
  429. return True
  430. return payload.get("childSessionId") == session_id
  431. related_id = payload.get("sessionId")
  432. return (
  433. isinstance(related_id, str)
  434. and self._session_is_descendant_of(related_id, session_id)
  435. )
  436. return belongs
  437. def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool:
  438. current = session_id
  439. visited: set[str] = set()
  440. while current not in visited:
  441. if current == root_session_id:
  442. return True
  443. visited.add(current)
  444. parent = self._session_parents.get(current)
  445. if parent is None:
  446. return False
  447. current = parent
  448. return False
  449. class NotificationSubscription:
  450. def __init__(
  451. self,
  452. client: HarnessClient,
  453. subscription_id: str,
  454. notifications: queue.Queue[Notification | BaseException],
  455. ) -> None:
  456. self._client = client
  457. self._subscription_id = subscription_id
  458. self._notifications = notifications
  459. self._closed = False
  460. def __enter__(self) -> "NotificationSubscription":
  461. return self
  462. def __exit__(self, _exc_type, _exc, _tb) -> None:
  463. self.close()
  464. def close(self) -> None:
  465. if self._closed:
  466. return
  467. self._closed = True
  468. self._client._unsubscribe_notifications(self._subscription_id)
  469. def next(self) -> Notification:
  470. item = self._notifications.get()
  471. if isinstance(item, BaseException):
  472. raise item
  473. return item
  474. def drain(self, on_notification: Callable[[Notification], None]) -> None:
  475. while True:
  476. try:
  477. item = self._notifications.get_nowait()
  478. except queue.Empty:
  479. return
  480. if isinstance(item, BaseException):
  481. raise item
  482. on_notification(item)
  483. class _SessionPromptResponse(BaseModel):
  484. accepted: Literal[True]
  485. class _ShutdownResponse(BaseModel):
  486. pass
  487. def _int_or_none(value: object) -> int | None:
  488. return value if isinstance(value, int) else None