fix(sdk): harden runtime lifecycle and JSON-RPC

Keep DeepSeekHarness.run() reusable, but make ownership of its lazy
runtime process explicit. Document the context-manager/close contract and
update every construction example to use a context manager so repeated runs
remain valid without encouraging leaked subprocesses.

Contain notification predicate failures at the subscription boundary. Remove
only the subscriber whose callback raised, deliver that exception through its
queue, and continue dispatching to healthy subscribers so arbitrary callback
code cannot terminate the shared reader thread or strand later requests.

Enforce one in-flight prompt per server session with an atomic activePrompt
guard. Route overlap through the existing -32603 handler-error response and
clear the guard in finally, preserving parallel prompts across sessions and
sequential reuse without changing JSON-RPC request or notification shapes.

Use StringDecoder for line framing so a UTF-8 code point split across Buffer
chunks is not corrupted. Add a queued-write flush barrier, and make memoized
shutdown await it before disposal and exit while retaining exactly-once
cleanup when shutdown calls race or flushing fails.

Cover callback isolation, same-session exclusion, cross-session concurrency,
split multibyte input, delayed writes, racing shutdown, and flush failure with
deterministic tests.
This commit is contained in:
Tianyi Cui
2026-07-13 21:40:12 +08:00
parent fe3777cf27
commit d5e894edf4
16 changed files with 315 additions and 55 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: d9403733b7daff9b3e8dbf21e1bc0c68d61b88a7
README.zh.md: 28859e43b7332a8ef4c3cf199b97860447a011a8
README.md: 35ed645ce0e4d5e4c88c8aae2fe33d94aea79b3e
README.zh.md: bddb709114df2e46bbea6e6faeb660bffa30df2b
+2 -1
View File
@@ -37,7 +37,8 @@ For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the rep
```python
from deepseek_harness import DeepSeekHarness
print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe
with DeepSeekHarness() as harness:
print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe
```
## Running the SDK against the Node source (no executable)
+2 -1
View File
@@ -37,7 +37,8 @@ uv run --project python/sdk pytest #
```python
from deepseek_harness import DeepSeekHarness
print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks the bundled exe
with DeepSeekHarness() as harness:
print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe
```
## 对着 Node 源码运行 SDK(不用可执行文件)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write
README.md: 1916086324e18ce79ea572f3c85115fda3b4ab91
README.zh.md: 380ed1726f2b541cd66403edb68a2629071e18f0
README.md: 60540376c5fd85b0852e204bc8bad3f01c849de5
README.zh.md: 7148a45c8f4bd22aaa28fc15ac9cd589cba13dfb
+4 -1
View File
@@ -13,9 +13,12 @@ Installing `deepseek-harness` installs the exact same-version `deepseek-harness-
```py
from deepseek_harness import DeepSeekHarness
result = DeepSeekHarness().run("Say hi.")
with DeepSeekHarness() as harness:
result = harness.run("Say hi.")
```
`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished.
By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path.
```py
+4 -1
View File
@@ -9,9 +9,12 @@
```py
from deepseek_harness import DeepSeekHarness
result = DeepSeekHarness().run("Say hi.")
with DeepSeekHarness() as harness:
result = harness.run("Say hi.")
```
`DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`
默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。
```py
+6 -1
View File
@@ -43,7 +43,12 @@ class TurnResult:
class DeepSeekHarness:
"""Synchronous high-level SDK for running DeepSeek Harness agent turns."""
"""Reusable synchronous SDK for running DeepSeek Harness agent turns.
The runtime subprocess starts lazily and remains owned by this instance
across calls to :meth:`run`. Use the instance as a context manager, or call
:meth:`close` explicitly when finished, so the subprocess is always reaped.
"""
def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None:
if config is not None and kwargs:
+12 -3
View File
@@ -350,10 +350,19 @@ class HarnessClient:
params = message.get("params")
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
with self._lock:
subscribers = list(self._notification_subscribers.values())
subscribers = list(self._notification_subscribers.items())
delivered = False
for subscriber, predicate in subscribers:
if predicate is None or predicate(notification):
for subscription_id, (subscriber, predicate) in subscribers:
try:
matches = predicate is None or predicate(notification)
except BaseException as exc:
with self._lock:
current = self._notification_subscribers.get(subscription_id)
if current is not None and current[0] is subscriber:
self._notification_subscribers.pop(subscription_id, None)
subscriber.put(exc)
continue
if matches:
subscriber.put(notification)
delivered = True
if not delivered:
+42
View File
@@ -356,6 +356,48 @@ def test_client_keeps_unmatched_notifications_available_globally_while_subscribe
assert notification.payload["sessionId"] == "other"
def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None:
script = tmp_path / "fake_bridge.py"
script.write_text(
"""
import json
import sys
for line in sys.stdin:
msg = json.loads(line)
method = msg.get("method")
if method == "initialize":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True)
elif method in {"emit-first", "emit-second"}:
print(json.dumps({"jsonrpc": "2.0", "method": "tick", "params": {"source": method}}), flush=True)
elif method == "session/prompt":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True)
elif method == "shutdown":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
break
""".strip()
)
def broken_filter(_notification: object) -> bool:
raise RuntimeError("bad notification filter")
with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client:
client.initialize(cwd="/workspace", model="dsagent")
with (
client.subscribe_notifications(broken_filter) as broken,
client.subscribe_notifications(lambda notification: notification.method == "tick") as healthy,
):
client.notify("emit-first")
with pytest.raises(RuntimeError, match="bad notification filter"):
broken.next()
assert healthy.next().payload == {"source": "emit-first"}
assert client._notifications.qsize() == 0
client.session_prompt("main", [{"type": "text", "text": "reader still works"}])
client.notify("emit-second")
assert healthy.next().payload == {"source": "emit-second"}
def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None:
script = tmp_path / "fake_bridge.py"
script.write_text(