python: deepseek-harness SDK and runtime carrier packages
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult
|
||||
from .client import HarnessClient, HarnessConfig
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
|
||||
|
||||
__all__ = [
|
||||
"DeepSeekHarness",
|
||||
"DeepSeekHarnessConfig",
|
||||
"Session",
|
||||
"TurnResult",
|
||||
"HarnessClient",
|
||||
"HarnessConfig",
|
||||
"IncomingRequest",
|
||||
"InitializeResponse",
|
||||
"JsonObject",
|
||||
"Notification",
|
||||
"ServerInfo",
|
||||
]
|
||||
@@ -0,0 +1,223 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
from .client import HarnessClient, HarnessConfig
|
||||
from .models import JsonObject, Notification
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeepSeekHarnessConfig:
|
||||
"""Configuration for launching the local DeepSeek Harness SDK runtime.
|
||||
|
||||
The runtime inherits the caller's environment by default, so existing
|
||||
DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to
|
||||
intentionally override or inject variables for a subprocess.
|
||||
"""
|
||||
|
||||
model: str = "deepseek-v4-flash"
|
||||
cwd: str | None = None
|
||||
runtime_cwd: str | None = None
|
||||
session_root: str | None = None
|
||||
cordis: str | None = None
|
||||
system_prompt: str | None = None
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
runtime_bin: str | None = None
|
||||
launch_args_override: tuple[str, ...] | None = None
|
||||
request_timeout_seconds: float | None = None
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
client_name: str = "deepseek_harness_python_sdk"
|
||||
client_version: str = "0.0.0-dev"
|
||||
base_url: str | None = None
|
||||
api_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TurnResult:
|
||||
session_id: str
|
||||
status: str
|
||||
final_response: str
|
||||
events: list[JsonObject]
|
||||
notifications: list[Notification]
|
||||
session_root: str | None = None
|
||||
|
||||
|
||||
class DeepSeekHarness:
|
||||
"""Synchronous high-level SDK for running DeepSeek Harness agent turns."""
|
||||
|
||||
def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None:
|
||||
if config is not None and kwargs:
|
||||
raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both")
|
||||
self.config = config or DeepSeekHarnessConfig(**kwargs)
|
||||
cwd = self.config.cwd or str(Path.cwd())
|
||||
runtime_cwd = self.config.runtime_cwd or cwd
|
||||
env = dict(self.config.env)
|
||||
if self.config.session_root is not None:
|
||||
env["DSH_SESSION_ROOT"] = self.config.session_root
|
||||
if self.config.cordis is not None:
|
||||
env["DSH_CORDIS_CONFIG"] = self.config.cordis
|
||||
else:
|
||||
self._inject_bundled_default_config(env)
|
||||
env["DSH_CWD"] = cwd
|
||||
if self.config.base_url is not None:
|
||||
env["DEEPSEEK_BASE_URL"] = self.config.base_url
|
||||
if self.config.api_key is not None:
|
||||
env["DEEPSEEK_API_KEY"] = self.config.api_key
|
||||
|
||||
self._client = HarnessClient(
|
||||
HarnessConfig(
|
||||
runtime_bin=self.config.runtime_bin,
|
||||
launch_args_override=self.config.launch_args_override,
|
||||
cwd=runtime_cwd,
|
||||
env=env,
|
||||
request_timeout_seconds=self.config.request_timeout_seconds,
|
||||
shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
|
||||
client_name=self.config.client_name,
|
||||
client_version=self.config.client_version,
|
||||
)
|
||||
)
|
||||
self._initialized = False
|
||||
|
||||
def __enter__(self) -> "DeepSeekHarness":
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
@property
|
||||
def client(self) -> HarnessClient:
|
||||
return self._client
|
||||
|
||||
def start(self) -> None:
|
||||
if self._initialized:
|
||||
return
|
||||
self._client.start()
|
||||
self._client.initialize(
|
||||
cwd=self.config.cwd or str(Path.cwd()),
|
||||
model=self.config.model,
|
||||
session_root=self.config.session_root,
|
||||
system_prompt=self.config.system_prompt,
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
self._initialized = False
|
||||
|
||||
def _inject_bundled_default_config(self, env: dict[str, str]) -> None:
|
||||
"""Restore the zero-config experience over the config-mandatory bundled runtime.
|
||||
|
||||
The bundled runtime (single-file exe or the dev-only node closure)
|
||||
always demands an explicit config. When the caller neither provided
|
||||
``cordis`` nor selected a runtime explicitly (``runtime_bin`` /
|
||||
``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists,
|
||||
inject the runtime package's checked-in default cordis.yml. With an
|
||||
explicit runtime or config channel the SDK stays out of the way.
|
||||
"""
|
||||
uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None
|
||||
if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ:
|
||||
return
|
||||
try:
|
||||
from deepseek_harness_runtime import bundled_default_config_path
|
||||
except ImportError:
|
||||
# Only the runtime package's absence reaches here; swallow it so
|
||||
# HarnessClient.start() reports the actionable install error.
|
||||
return
|
||||
env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path())
|
||||
|
||||
def start_session(self, session_id: str | None = None) -> "Session":
|
||||
self.start()
|
||||
return Session(self, session_id or f"session-{uuid.uuid4().hex}")
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str | list[JsonObject],
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
profile: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> TurnResult:
|
||||
return self.start_session(session_id).run(input, profile=profile, on_notification=on_notification)
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
|
||||
self.harness = harness
|
||||
self.id = session_id
|
||||
|
||||
def run(
|
||||
self,
|
||||
input: str | list[JsonObject],
|
||||
*,
|
||||
profile: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> TurnResult:
|
||||
content_blocks = normalize_input(input)
|
||||
notifications: list[Notification] = []
|
||||
events: list[JsonObject] = []
|
||||
status = "error"
|
||||
finished = False
|
||||
|
||||
def collect(notification: Notification) -> None:
|
||||
nonlocal finished, status
|
||||
notifications.append(notification)
|
||||
if on_notification is not None:
|
||||
on_notification(notification)
|
||||
if notification.method == "session.event":
|
||||
event = notification.payload.get("event")
|
||||
if isinstance(event, dict):
|
||||
events.append(event)
|
||||
if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id:
|
||||
status = str(notification.payload.get("status") or "ok")
|
||||
finished = True
|
||||
|
||||
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
|
||||
self.harness.client.session_prompt(
|
||||
self.id,
|
||||
content_blocks,
|
||||
profile=profile,
|
||||
on_notification=collect,
|
||||
notification_subscription=subscription,
|
||||
)
|
||||
|
||||
while not finished:
|
||||
notification = subscription.next()
|
||||
collect(notification)
|
||||
|
||||
return TurnResult(
|
||||
session_id=self.id,
|
||||
status=status,
|
||||
final_response=final_response(events),
|
||||
events=events,
|
||||
notifications=notifications,
|
||||
session_root=self.harness.config.session_root,
|
||||
)
|
||||
|
||||
|
||||
def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
|
||||
if isinstance(input, str):
|
||||
return [{"type": "text", "text": input}]
|
||||
return input
|
||||
|
||||
|
||||
def final_response(events: list[JsonObject]) -> str:
|
||||
for event in reversed(events):
|
||||
if event.get("type") != "assistant/message":
|
||||
continue
|
||||
data = event.get("data")
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
content = data.get("content")
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
parts: list[str] = []
|
||||
for block in content:
|
||||
if isinstance(block, dict) and block.get("type") == "text":
|
||||
parts.append(str(block.get("text") or ""))
|
||||
return "".join(parts)
|
||||
return ""
|
||||
@@ -0,0 +1,481 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable, Literal, TypeAlias, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .errors import JsonRpcError, TransportClosedError
|
||||
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification
|
||||
|
||||
ModelT = TypeVar("ModelT", bound=BaseModel)
|
||||
NotificationFilter: TypeAlias = Callable[[Notification], bool]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class HarnessConfig:
|
||||
"""Configuration for launching the local DeepSeek Harness SDK runtime."""
|
||||
|
||||
runtime_bin: str | None = None
|
||||
bridge_bin: str | None = None
|
||||
launch_args_override: tuple[str, ...] | None = None
|
||||
cwd: str | None = None
|
||||
env: dict[str, str] | None = None
|
||||
request_timeout_seconds: float | None = None
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
client_name: str = "deepseek_harness_python_sdk"
|
||||
client_version: str = "0.0.0-dev"
|
||||
|
||||
|
||||
class HarnessClient:
|
||||
"""Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
|
||||
|
||||
def __init__(self, config: HarnessConfig | None = None) -> None:
|
||||
self.config = config or HarnessConfig()
|
||||
self._proc: subprocess.Popen[str] | None = None
|
||||
self._lock = threading.Lock()
|
||||
self._write_lock = threading.Lock()
|
||||
self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {}
|
||||
self._notifications: queue.Queue[Notification | BaseException] = queue.Queue()
|
||||
self._notification_subscribers: dict[
|
||||
str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None]
|
||||
] = {}
|
||||
self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue()
|
||||
self._stderr_lines: deque[str] = deque(maxlen=400)
|
||||
self._reader_thread: threading.Thread | None = None
|
||||
self._stderr_thread: threading.Thread | None = None
|
||||
|
||||
def __enter__(self) -> "HarnessClient":
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
def start(self) -> None:
|
||||
if self._proc is not None:
|
||||
return
|
||||
args = list(self.config.launch_args_override or self._default_launch_args())
|
||||
env = os.environ.copy()
|
||||
if self.config.env:
|
||||
env.update(self.config.env)
|
||||
self._proc = subprocess.Popen(
|
||||
args,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=self.config.cwd,
|
||||
env=env,
|
||||
bufsize=1,
|
||||
)
|
||||
self._start_reader_thread()
|
||||
self._start_stderr_thread()
|
||||
|
||||
def close(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None:
|
||||
return
|
||||
try:
|
||||
self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds)
|
||||
except Exception as exc:
|
||||
self._stderr_lines.append(f"shutdown request failed: {exc}")
|
||||
self._proc = None
|
||||
if proc.stdin:
|
||||
try:
|
||||
proc.stdin.close()
|
||||
except Exception as exc:
|
||||
self._stderr_lines.append(f"stdin close failed: {exc}")
|
||||
try:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
proc.kill()
|
||||
self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed"))
|
||||
if self._reader_thread and self._reader_thread.is_alive():
|
||||
self._reader_thread.join(timeout=0.5)
|
||||
if self._stderr_thread and self._stderr_thread.is_alive():
|
||||
self._stderr_thread.join(timeout=0.5)
|
||||
|
||||
def initialize(
|
||||
self,
|
||||
*,
|
||||
cwd: str,
|
||||
model: str,
|
||||
session_root: str | None = None,
|
||||
system_prompt: str | None = None,
|
||||
) -> InitializeResponse:
|
||||
payload: JsonObject = {
|
||||
"clientInfo": {
|
||||
"name": self.config.client_name,
|
||||
"version": self.config.client_version,
|
||||
},
|
||||
"cwd": cwd,
|
||||
"model": model,
|
||||
}
|
||||
if session_root is not None:
|
||||
payload["sessionRoot"] = session_root
|
||||
if system_prompt is not None:
|
||||
payload["systemPrompt"] = system_prompt
|
||||
return self.request("initialize", payload, response_model=InitializeResponse)
|
||||
|
||||
def session_prompt(
|
||||
self,
|
||||
session_id: str,
|
||||
content_blocks: list[JsonObject],
|
||||
*,
|
||||
profile: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
notification_subscription: "NotificationSubscription | None" = None,
|
||||
) -> None:
|
||||
payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks}
|
||||
if profile is not None:
|
||||
payload["profile"] = profile
|
||||
self.request(
|
||||
"session/prompt",
|
||||
payload,
|
||||
response_model=_SessionPromptResponse,
|
||||
on_notification=on_notification,
|
||||
notification_filter=_notification_belongs_to_session(session_id),
|
||||
notification_subscription=notification_subscription,
|
||||
)
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None,
|
||||
*,
|
||||
response_model: type[ModelT],
|
||||
timeout_seconds: float | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
notification_filter: NotificationFilter | None = None,
|
||||
notification_subscription: "NotificationSubscription | None" = None,
|
||||
) -> ModelT:
|
||||
result = self._request_raw(
|
||||
method,
|
||||
params,
|
||||
timeout_seconds=timeout_seconds,
|
||||
on_notification=on_notification,
|
||||
notification_filter=notification_filter,
|
||||
notification_subscription=notification_subscription,
|
||||
)
|
||||
if not isinstance(result, dict):
|
||||
raise TypeError(f"{method} response must be a JSON object")
|
||||
return response_model.model_validate(result)
|
||||
|
||||
def notify(self, method: str, params: JsonObject | None = None) -> None:
|
||||
message: JsonObject = {"jsonrpc": "2.0", "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self._write_message(message)
|
||||
|
||||
def next_notification(self) -> Notification:
|
||||
item = self._notifications.get()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def subscribe_notifications(
|
||||
self,
|
||||
notification_filter: NotificationFilter | None = None,
|
||||
) -> "NotificationSubscription":
|
||||
subscription_id = str(uuid.uuid4())
|
||||
notifications: queue.Queue[Notification | BaseException] = queue.Queue()
|
||||
with self._lock:
|
||||
self._notification_subscribers[subscription_id] = (notifications, notification_filter)
|
||||
return NotificationSubscription(self, subscription_id, notifications)
|
||||
|
||||
def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription":
|
||||
return self.subscribe_notifications(_notification_belongs_to_session(session_id))
|
||||
|
||||
def next_request(self) -> IncomingRequest:
|
||||
item = self._requests.get()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def respond(self, request_id: str | int, result: JsonValue) -> None:
|
||||
self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result})
|
||||
|
||||
def respond_error(
|
||||
self,
|
||||
request_id: str | int,
|
||||
*,
|
||||
code: int,
|
||||
message: str,
|
||||
data: JsonValue | None = None,
|
||||
) -> None:
|
||||
error: JsonObject = {"code": code, "message": message}
|
||||
if data is not None:
|
||||
error["data"] = data
|
||||
self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error})
|
||||
|
||||
def _request_raw(
|
||||
self,
|
||||
method: str,
|
||||
params: JsonObject | None = None,
|
||||
*,
|
||||
timeout_seconds: float | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
notification_filter: NotificationFilter | None = None,
|
||||
notification_subscription: "NotificationSubscription | None" = None,
|
||||
) -> JsonValue:
|
||||
request_id = str(uuid.uuid4())
|
||||
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
|
||||
temp_subscription: NotificationSubscription | None = None
|
||||
subscription = notification_subscription
|
||||
with self._lock:
|
||||
self._responses[request_id] = waiter
|
||||
if on_notification is not None and subscription is None:
|
||||
temp_subscription = self.subscribe_notifications(notification_filter)
|
||||
subscription = temp_subscription
|
||||
try:
|
||||
message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
|
||||
if params is not None:
|
||||
message["params"] = params
|
||||
self._write_message(message)
|
||||
except BaseException:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
raise
|
||||
timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
try:
|
||||
while True:
|
||||
if on_notification is not None and subscription is not None:
|
||||
subscription.drain(on_notification)
|
||||
wait_timeout = None
|
||||
if on_notification is not None:
|
||||
wait_timeout = 0.05
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime")
|
||||
wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
|
||||
try:
|
||||
item = waiter.get(timeout=wait_timeout)
|
||||
if on_notification is not None and subscription is not None:
|
||||
subscription.drain(on_notification)
|
||||
break
|
||||
except queue.Empty:
|
||||
continue
|
||||
except BaseException:
|
||||
with self._lock:
|
||||
self._responses.pop(request_id, None)
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
raise
|
||||
finally:
|
||||
if temp_subscription is not None:
|
||||
temp_subscription.close()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def _write_message(self, message: JsonObject) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdin is None:
|
||||
raise TransportClosedError("DeepSeek Harness runtime is not running")
|
||||
try:
|
||||
payload = json.dumps(message, separators=(",", ":")) + "\n"
|
||||
with self._write_lock:
|
||||
proc.stdin.write(payload)
|
||||
proc.stdin.flush()
|
||||
except Exception as exc:
|
||||
raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc
|
||||
|
||||
def _start_reader_thread(self) -> None:
|
||||
self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True)
|
||||
self._reader_thread.start()
|
||||
|
||||
def _start_stderr_thread(self) -> None:
|
||||
self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True)
|
||||
self._stderr_thread.start()
|
||||
|
||||
def _reader_loop(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stdout is None:
|
||||
return
|
||||
try:
|
||||
for line in proc.stdout:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
message = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
self._handle_message(message)
|
||||
except BaseException as exc:
|
||||
self._fail_waiters(exc)
|
||||
finally:
|
||||
self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed"))
|
||||
|
||||
def _stderr_loop(self) -> None:
|
||||
proc = self._proc
|
||||
if proc is None or proc.stderr is None:
|
||||
return
|
||||
for line in proc.stderr:
|
||||
self._stderr_lines.append(line.rstrip())
|
||||
|
||||
def _handle_message(self, message: object) -> None:
|
||||
if not isinstance(message, dict):
|
||||
return
|
||||
msg_id = message.get("id")
|
||||
method = message.get("method")
|
||||
if isinstance(msg_id, (str, int)) and isinstance(method, str):
|
||||
params = message.get("params")
|
||||
self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}))
|
||||
return
|
||||
if isinstance(msg_id, (str, int)):
|
||||
with self._lock:
|
||||
waiter = self._responses.pop(str(msg_id), None)
|
||||
if waiter is None:
|
||||
return
|
||||
if isinstance(message.get("error"), dict):
|
||||
err = message["error"]
|
||||
waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data")))
|
||||
else:
|
||||
waiter.put(message.get("result"))
|
||||
return
|
||||
if isinstance(method, str):
|
||||
params = message.get("params")
|
||||
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
|
||||
with self._lock:
|
||||
subscribers = list(self._notification_subscribers.values())
|
||||
delivered = False
|
||||
for subscriber, predicate in subscribers:
|
||||
if predicate is None or predicate(notification):
|
||||
subscriber.put(notification)
|
||||
delivered = True
|
||||
if not delivered:
|
||||
self._notifications.put(notification)
|
||||
|
||||
def _fail_waiters(self, exc: BaseException) -> None:
|
||||
with self._lock:
|
||||
waiters = list(self._responses.values())
|
||||
self._responses.clear()
|
||||
subscribers = list(self._notification_subscribers.values())
|
||||
self._notification_subscribers.clear()
|
||||
for waiter in waiters:
|
||||
waiter.put(exc)
|
||||
for subscriber, _predicate in subscribers:
|
||||
subscriber.put(exc)
|
||||
self._notifications.put(exc)
|
||||
self._requests.put(exc)
|
||||
|
||||
def _runtime_closed_error(self, reason: str) -> TransportClosedError:
|
||||
proc = self._proc
|
||||
if (
|
||||
proc is not None
|
||||
and proc.poll() is not None
|
||||
and self._stderr_thread is not None
|
||||
and self._stderr_thread.is_alive()
|
||||
and threading.current_thread() is not self._stderr_thread
|
||||
):
|
||||
self._stderr_thread.join(timeout=0.1)
|
||||
|
||||
parts = [reason]
|
||||
if proc is not None:
|
||||
exit_code = proc.poll()
|
||||
if exit_code is not None:
|
||||
parts.append(f"exit code: {exit_code}")
|
||||
if self._stderr_lines:
|
||||
parts.append("stderr tail:\n" + "\n".join(self._stderr_lines))
|
||||
return TransportClosedError("\n".join(parts))
|
||||
|
||||
def _default_launch_args(self) -> tuple[str, ...]:
|
||||
if self.config.runtime_bin is not None:
|
||||
return (self.config.runtime_bin,)
|
||||
if self.config.bridge_bin is not None:
|
||||
return (self.config.bridge_bin,)
|
||||
try:
|
||||
from deepseek_harness_runtime import resolve_bundled_launch_args
|
||||
except ImportError as exc:
|
||||
raise FileNotFoundError(
|
||||
"Unable to locate the bundled DeepSeek Harness SDK runtime. "
|
||||
"Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin."
|
||||
) from exc
|
||||
return resolve_bundled_launch_args()
|
||||
|
||||
def _unsubscribe_notifications(self, subscription_id: str) -> None:
|
||||
with self._lock:
|
||||
self._notification_subscribers.pop(subscription_id, None)
|
||||
|
||||
|
||||
class NotificationSubscription:
|
||||
def __init__(
|
||||
self,
|
||||
client: HarnessClient,
|
||||
subscription_id: str,
|
||||
notifications: queue.Queue[Notification | BaseException],
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._subscription_id = subscription_id
|
||||
self._notifications = notifications
|
||||
self._closed = False
|
||||
|
||||
def __enter__(self) -> "NotificationSubscription":
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb) -> None:
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
self._client._unsubscribe_notifications(self._subscription_id)
|
||||
|
||||
def next(self) -> Notification:
|
||||
item = self._notifications.get()
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return item
|
||||
|
||||
def drain(self, on_notification: Callable[[Notification], None]) -> None:
|
||||
while True:
|
||||
try:
|
||||
item = self._notifications.get_nowait()
|
||||
except queue.Empty:
|
||||
return
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
on_notification(item)
|
||||
|
||||
|
||||
class _SessionPromptResponse(BaseModel):
|
||||
accepted: Literal[True]
|
||||
|
||||
|
||||
class _ShutdownResponse(BaseModel):
|
||||
pass
|
||||
|
||||
|
||||
def _int_or_none(value: object) -> int | None:
|
||||
return value if isinstance(value, int) else None
|
||||
|
||||
|
||||
def _notification_belongs_to_session(session_id: str) -> NotificationFilter:
|
||||
def belongs(notification: Notification) -> bool:
|
||||
payload = notification.payload
|
||||
return (
|
||||
payload.get("sessionId") == session_id
|
||||
or payload.get("parentSessionId") == session_id
|
||||
or payload.get("childSessionId") == session_id
|
||||
)
|
||||
|
||||
return belongs
|
||||
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class HarnessError(Exception):
|
||||
"""Base exception for SDK and runtime failures."""
|
||||
|
||||
|
||||
class TransportClosedError(HarnessError):
|
||||
"""Raised when the runtime subprocess exits or closes stdout."""
|
||||
|
||||
|
||||
class JsonRpcError(HarnessError):
|
||||
"""Raised when the runtime returns a JSON-RPC error response."""
|
||||
|
||||
def __init__(self, code: int | None, message: str, data: object | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.data = data
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TypeAlias
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
JsonScalar: TypeAlias = str | int | float | bool | None
|
||||
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
|
||||
JsonObject: TypeAlias = dict[str, JsonValue]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class Notification:
|
||||
method: str
|
||||
payload: JsonObject
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class IncomingRequest:
|
||||
id: str | int
|
||||
method: str
|
||||
payload: JsonObject
|
||||
|
||||
|
||||
class ServerInfo(BaseModel):
|
||||
name: str | None = None
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class InitializeResponse(BaseModel):
|
||||
serverInfo: ServerInfo | None = None
|
||||
Reference in New Issue
Block a user