client.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589
  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, 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. dsh_bin: str | None = None
  22. profile: str = "sdk"
  23. patches: tuple[str, ...] = ()
  24. dsh_home: str | None = None
  25. cwd: str | None = None
  26. env: dict[str, str] | None = None
  27. initialize_timeout_seconds: float = 30.0
  28. request_timeout_seconds: float | None = None
  29. shutdown_timeout_seconds: float | None = 1.0
  30. class HarnessClient:
  31. """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
  32. def __init__(
  33. self,
  34. config: HarnessConfig | None = None,
  35. *,
  36. _launch_args: tuple[str, ...] | None = None,
  37. ) -> None:
  38. self.config = config or HarnessConfig()
  39. self._launch_args = _launch_args
  40. self._proc: subprocess.Popen[str] | None = None
  41. self._lock = threading.Lock()
  42. self._write_lock = threading.Lock()
  43. self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {}
  44. self._notifications: queue.Queue[Notification | BaseException] = queue.Queue()
  45. self._notification_subscribers: dict[
  46. str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None]
  47. ] = {}
  48. self._session_parents: dict[str, str] = {}
  49. self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue()
  50. self._stderr_lines: deque[str] = deque(maxlen=400)
  51. self._reader_thread: threading.Thread | None = None
  52. self._stderr_thread: threading.Thread | None = None
  53. def __enter__(self) -> "HarnessClient":
  54. self.start()
  55. return self
  56. def __exit__(self, _exc_type, _exc, _tb) -> None:
  57. self.close()
  58. def start(self) -> None:
  59. if self._proc is not None:
  60. return
  61. with self._lock:
  62. self._session_parents.clear()
  63. env = os.environ.copy()
  64. if self.config.env:
  65. env.update(self.config.env)
  66. args = list(self._launch_args or self._default_launch_args(env))
  67. self._proc = subprocess.Popen(
  68. args,
  69. stdin=subprocess.PIPE,
  70. stdout=subprocess.PIPE,
  71. stderr=subprocess.PIPE,
  72. text=True,
  73. encoding="utf-8",
  74. cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()),
  75. env=env,
  76. bufsize=1,
  77. )
  78. self._start_reader_thread()
  79. self._start_stderr_thread()
  80. def close(self) -> None:
  81. """Close the runtime after a bounded opportunity to flush durable state."""
  82. proc = self._proc
  83. if proc is None:
  84. return
  85. shutdown_completed = False
  86. try:
  87. self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
  88. shutdown_completed = True
  89. except Exception as exc:
  90. self._stderr_lines.append(f"shutdown request failed: {exc}")
  91. if proc.stdin:
  92. try:
  93. proc.stdin.close()
  94. except Exception as exc:
  95. self._stderr_lines.append(f"stdin close failed: {exc}")
  96. if shutdown_completed:
  97. try:
  98. proc.wait(timeout=self.config.shutdown_timeout_seconds)
  99. except subprocess.TimeoutExpired:
  100. pass
  101. if proc.poll() is None:
  102. try:
  103. proc.terminate()
  104. except ProcessLookupError:
  105. pass
  106. if proc.poll() is None:
  107. try:
  108. proc.wait(timeout=self.config.shutdown_timeout_seconds)
  109. except subprocess.TimeoutExpired:
  110. proc.kill()
  111. proc.wait()
  112. self._proc = None
  113. self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed"))
  114. if self._reader_thread and self._reader_thread.is_alive():
  115. self._reader_thread.join(timeout=0.5)
  116. if self._stderr_thread and self._stderr_thread.is_alive():
  117. self._stderr_thread.join(timeout=0.5)
  118. def initialize(
  119. self,
  120. *,
  121. cwd: str,
  122. provider: str,
  123. model: str,
  124. reasoning_effort: str | None = None,
  125. max_tokens: int | None = None,
  126. ) -> InitializeResponse:
  127. payload: JsonObject = {
  128. "cwd": str(Path(cwd).resolve()),
  129. "provider": provider,
  130. "model": model,
  131. }
  132. if reasoning_effort is not None:
  133. payload["reasoningEffort"] = reasoning_effort
  134. if max_tokens is not None:
  135. payload["maxTokens"] = max_tokens
  136. try:
  137. return self.request(
  138. "initialize",
  139. payload,
  140. response_model=InitializeResponse,
  141. timeout_seconds=self.config.initialize_timeout_seconds,
  142. )
  143. except TimeoutError as error:
  144. self.close()
  145. raise TimeoutError(f"{error}\nselected dsh profile {self.config.profile!r}") from error
  146. except BaseException as error:
  147. self.close()
  148. diagnostics = self._runtime_diagnostics()
  149. if isinstance(error, JsonRpcError) and diagnostics:
  150. raise JsonRpcError(
  151. error.code,
  152. f"{error.message}\n{diagnostics}",
  153. error.data,
  154. ) from error
  155. raise
  156. def session_prompt(
  157. self,
  158. session_id: str,
  159. content_blocks: list[JsonObject],
  160. *,
  161. on_notification: Callable[[Notification], None] | None = None,
  162. notification_subscription: "NotificationSubscription | None" = None,
  163. ) -> str:
  164. payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks}
  165. response = self.request(
  166. "session/prompt",
  167. payload,
  168. response_model=_SessionPromptResponse,
  169. on_notification=on_notification,
  170. notification_filter=self._notification_belongs_to_session_tree(session_id),
  171. notification_subscription=notification_subscription,
  172. )
  173. return response.messageId
  174. def request(
  175. self,
  176. method: str,
  177. params: JsonObject | None,
  178. *,
  179. response_model: type[ModelT],
  180. timeout_seconds: float | None = None,
  181. on_notification: Callable[[Notification], None] | None = None,
  182. notification_filter: NotificationFilter | None = None,
  183. notification_subscription: "NotificationSubscription | None" = None,
  184. ) -> ModelT:
  185. result = self._request_raw(
  186. method,
  187. params,
  188. timeout_seconds=timeout_seconds,
  189. on_notification=on_notification,
  190. notification_filter=notification_filter,
  191. notification_subscription=notification_subscription,
  192. )
  193. if not isinstance(result, dict):
  194. raise TypeError(f"{method} response must be a JSON object")
  195. return response_model.model_validate(result)
  196. def notify(self, method: str, params: JsonObject | None = None) -> None:
  197. message: JsonObject = {"jsonrpc": "2.0", "method": method}
  198. if params is not None:
  199. message["params"] = params
  200. self._write_message(message)
  201. def next_notification(self) -> Notification:
  202. item = self._notifications.get()
  203. if isinstance(item, BaseException):
  204. raise item
  205. return item
  206. def subscribe_notifications(
  207. self,
  208. notification_filter: NotificationFilter | None = None,
  209. ) -> "NotificationSubscription":
  210. subscription_id = str(uuid.uuid4())
  211. notifications: queue.Queue[Notification | BaseException] = queue.Queue()
  212. with self._lock:
  213. self._notification_subscribers[subscription_id] = (notifications, notification_filter)
  214. return NotificationSubscription(self, subscription_id, notifications)
  215. def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
  216. """Subscribe to a session and descendants discovered from subagent lifecycle edges."""
  217. return self.subscribe_notifications(self._notification_belongs_to_session_tree(session_id))
  218. def next_request(self) -> IncomingRequest:
  219. item = self._requests.get()
  220. if isinstance(item, BaseException):
  221. raise item
  222. return item
  223. def respond(self, request_id: str | int, result: JsonValue) -> None:
  224. self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
  225. def respond_error(
  226. self,
  227. request_id: str | int,
  228. *,
  229. code: int,
  230. message: str,
  231. data: JsonValue | None = None,
  232. ) -> None:
  233. error: JsonObject = {"code": code, "message": message}
  234. if data is not None:
  235. error["data"] = data
  236. self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error})
  237. def _request_raw(
  238. self,
  239. method: str,
  240. params: JsonObject | None = None,
  241. *,
  242. timeout_seconds: float | None = None,
  243. on_notification: Callable[[Notification], None] | None = None,
  244. notification_filter: NotificationFilter | None = None,
  245. notification_subscription: "NotificationSubscription | None" = None,
  246. ) -> JsonValue:
  247. request_id = str(uuid.uuid4())
  248. waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
  249. temp_subscription: NotificationSubscription | None = None
  250. subscription = notification_subscription
  251. with self._lock:
  252. self._responses[request_id] = waiter
  253. if on_notification is not None and subscription is None:
  254. temp_subscription = self.subscribe_notifications(notification_filter)
  255. subscription = temp_subscription
  256. try:
  257. message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
  258. if params is not None:
  259. message["params"] = params
  260. self._write_message(message)
  261. except BaseException:
  262. with self._lock:
  263. self._responses.pop(request_id, None)
  264. if temp_subscription is not None:
  265. temp_subscription.close()
  266. raise
  267. timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
  268. deadline = None if timeout is None else time.monotonic() + timeout
  269. try:
  270. while True:
  271. if on_notification is not None and subscription is not None:
  272. subscription.drain(on_notification)
  273. wait_timeout = None
  274. if on_notification is not None:
  275. wait_timeout = 0.05
  276. if deadline is not None:
  277. remaining = deadline - time.monotonic()
  278. if remaining <= 0:
  279. with self._lock:
  280. self._responses.pop(request_id, None)
  281. diagnostics = self._runtime_diagnostics()
  282. suffix = f"\n{diagnostics}" if diagnostics else ""
  283. raise TimeoutError(
  284. f"{method} timed out waiting for DeepSeek Harness runtime{suffix}"
  285. )
  286. wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
  287. try:
  288. item = waiter.get(timeout=wait_timeout)
  289. if on_notification is not None and subscription is not None:
  290. subscription.drain(on_notification)
  291. break
  292. except queue.Empty:
  293. continue
  294. except BaseException:
  295. with self._lock:
  296. self._responses.pop(request_id, None)
  297. if temp_subscription is not None:
  298. temp_subscription.close()
  299. raise
  300. finally:
  301. if temp_subscription is not None:
  302. temp_subscription.close()
  303. if isinstance(item, BaseException):
  304. raise item
  305. return item
  306. def _write_message(self, message: JsonObject) -> None:
  307. proc = self._proc
  308. if proc is None or proc.stdin is None:
  309. raise TransportClosedError("DeepSeek Harness runtime is not running")
  310. try:
  311. payload = json.dumps(message, separators=(",", ":")) + "\n"
  312. with self._write_lock:
  313. proc.stdin.write(payload)
  314. proc.stdin.flush()
  315. except Exception as exc:
  316. raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc
  317. def _start_reader_thread(self) -> None:
  318. self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True)
  319. self._reader_thread.start()
  320. def _start_stderr_thread(self) -> None:
  321. self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True)
  322. self._stderr_thread.start()
  323. def _reader_loop(self) -> None:
  324. proc = self._proc
  325. if proc is None or proc.stdout is None:
  326. return
  327. try:
  328. for line in proc.stdout:
  329. if not line.strip():
  330. continue
  331. try:
  332. message = json.loads(line)
  333. except json.JSONDecodeError:
  334. continue
  335. self._handle_message(message)
  336. except BaseException as exc:
  337. self._fail_waiters(exc)
  338. finally:
  339. self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed"))
  340. def _stderr_loop(self) -> None:
  341. proc = self._proc
  342. if proc is None or proc.stderr is None:
  343. return
  344. for line in proc.stderr:
  345. self._stderr_lines.append(line.rstrip())
  346. def _handle_message(self, message: object) -> None:
  347. if not isinstance(message, dict):
  348. return
  349. msg_id = message.get("id")
  350. method = message.get("method")
  351. if isinstance(msg_id, (str, int)) and isinstance(method, str):
  352. params = message.get("params")
  353. self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}))
  354. return
  355. if isinstance(msg_id, (str, int)):
  356. with self._lock:
  357. waiter = self._responses.pop(str(msg_id), None)
  358. if waiter is None:
  359. return
  360. if isinstance(message.get("error"), dict):
  361. err = message["error"]
  362. waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data")))
  363. else:
  364. waiter.put(message.get("result"))
  365. return
  366. if isinstance(method, str):
  367. params = message.get("params")
  368. notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
  369. with self._lock:
  370. self._record_session_relationship_locked(notification)
  371. subscribers = list(self._notification_subscribers.items())
  372. delivered = False
  373. for subscription_id, (subscriber, predicate) in subscribers:
  374. try:
  375. matches = predicate is None or predicate(notification)
  376. except BaseException as exc:
  377. with self._lock:
  378. current = self._notification_subscribers.get(subscription_id)
  379. if current is not None and current[0] is subscriber:
  380. self._notification_subscribers.pop(subscription_id, None)
  381. subscriber.put(exc)
  382. continue
  383. if matches:
  384. subscriber.put(notification)
  385. delivered = True
  386. if not delivered:
  387. self._notifications.put(notification)
  388. def _fail_waiters(self, exc: BaseException) -> None:
  389. with self._lock:
  390. waiters = list(self._responses.values())
  391. self._responses.clear()
  392. subscribers = list(self._notification_subscribers.values())
  393. self._notification_subscribers.clear()
  394. for waiter in waiters:
  395. waiter.put(exc)
  396. for subscriber, _predicate in subscribers:
  397. subscriber.put(exc)
  398. self._notifications.put(exc)
  399. self._requests.put(exc)
  400. def _runtime_closed_error(self, reason: str) -> TransportClosedError:
  401. diagnostics = self._runtime_diagnostics()
  402. return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason)
  403. def _runtime_diagnostics(self) -> str:
  404. """Return available subprocess state for transport failures and timeouts."""
  405. proc = self._proc
  406. if (
  407. proc is not None
  408. and proc.poll() is not None
  409. and self._stderr_thread is not None
  410. and self._stderr_thread.is_alive()
  411. and threading.current_thread() is not self._stderr_thread
  412. ):
  413. self._stderr_thread.join(timeout=0.1)
  414. parts: list[str] = []
  415. if proc is not None:
  416. exit_code = proc.poll()
  417. if exit_code is not None:
  418. parts.append(f"exit code: {exit_code}")
  419. if self._stderr_lines:
  420. parts.append("stderr tail:\n" + "\n".join(self._stderr_lines))
  421. return "\n".join(parts)
  422. def _default_launch_args(self, env: dict[str, str]) -> tuple[str, ...]:
  423. if self.config.dsh_bin is None:
  424. try:
  425. from deepseek_harness_runtime import resolve_bundled_launch_args
  426. except ImportError as exc:
  427. raise FileNotFoundError(
  428. "Unable to locate the bundled DeepSeek Harness dsh runtime. "
  429. "Install deepseek-harness-runtime-bin."
  430. ) from exc
  431. base = resolve_bundled_launch_args()
  432. else:
  433. base = (str(Path(self.config.dsh_bin).expanduser().resolve()),)
  434. if self.config.dsh_home is not None:
  435. if not self.config.dsh_home.strip():
  436. raise ValueError("HarnessConfig requires a non-empty dsh_home")
  437. env["DSH_HOME"] = str(Path(self.config.dsh_home).expanduser().resolve())
  438. elif not env.get("DSH_HOME", "").strip():
  439. raise ValueError(
  440. "HarnessConfig requires an explicit dsh_home or non-empty DSH_HOME; "
  441. "the Python SDK never uses ~/.dsh implicitly"
  442. )
  443. patches = tuple(
  444. argument
  445. for patch in self.config.patches
  446. for argument in ("--patch", str(Path(patch).expanduser().resolve()))
  447. )
  448. return (*base, "--profile", self.config.profile, *patches)
  449. def _unsubscribe_notifications(self, subscription_id: str) -> None:
  450. with self._lock:
  451. self._notification_subscribers.pop(subscription_id, None)
  452. def _record_session_relationship_locked(self, notification: Notification) -> None:
  453. if notification.method != "subagent.started":
  454. return
  455. parent_id = notification.payload.get("parentSessionId")
  456. child_id = notification.payload.get("childSessionId")
  457. if (
  458. isinstance(parent_id, str)
  459. and parent_id
  460. and isinstance(child_id, str)
  461. and child_id
  462. and parent_id != child_id
  463. ):
  464. self._session_parents[child_id] = parent_id
  465. def _notification_belongs_to_session_tree(self, session_id: str) -> NotificationFilter:
  466. def belongs(notification: Notification) -> bool:
  467. payload = notification.payload
  468. if notification.method in {"subagent.started", "subagent.finished"}:
  469. parent_id = payload.get("parentSessionId")
  470. if (
  471. isinstance(parent_id, str)
  472. and self._session_is_descendant_of(parent_id, session_id)
  473. ):
  474. return True
  475. return payload.get("childSessionId") == session_id
  476. related_id = payload.get("sessionId")
  477. return (
  478. isinstance(related_id, str)
  479. and self._session_is_descendant_of(related_id, session_id)
  480. )
  481. return belongs
  482. def _session_is_descendant_of(self, session_id: str, root_session_id: str) -> bool:
  483. current = session_id
  484. visited: set[str] = set()
  485. while current not in visited:
  486. if current == root_session_id:
  487. return True
  488. visited.add(current)
  489. parent = self._session_parents.get(current)
  490. if parent is None:
  491. return False
  492. current = parent
  493. return False
  494. class NotificationSubscription:
  495. def __init__(
  496. self,
  497. client: HarnessClient,
  498. subscription_id: str,
  499. notifications: queue.Queue[Notification | BaseException],
  500. ) -> None:
  501. self._client = client
  502. self._subscription_id = subscription_id
  503. self._notifications = notifications
  504. self._closed = False
  505. def __enter__(self) -> "NotificationSubscription":
  506. return self
  507. def __exit__(self, _exc_type, _exc, _tb) -> None:
  508. self.close()
  509. def close(self) -> None:
  510. if self._closed:
  511. return
  512. self._closed = True
  513. self._client._unsubscribe_notifications(self._subscription_id)
  514. def next(self) -> Notification:
  515. item = self._notifications.get()
  516. if isinstance(item, BaseException):
  517. raise item
  518. return item
  519. def drain(self, on_notification: Callable[[Notification], None]) -> None:
  520. while True:
  521. try:
  522. item = self._notifications.get_nowait()
  523. except queue.Empty:
  524. return
  525. if isinstance(item, BaseException):
  526. raise item
  527. on_notification(item)
  528. class _SessionPromptResponse(BaseModel):
  529. messageId: str
  530. class _ShutdownResponse(BaseModel):
  531. pass
  532. def _int_or_none(value: object) -> int | None:
  533. return value if isinstance(value, int) else None