jsonrpc: harden Python SDK lifecycle and protocol
This commit is contained in:
@@ -23,14 +23,11 @@ class DeepSeekHarnessConfig:
|
||||
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
|
||||
|
||||
@@ -52,8 +49,9 @@ class DeepSeekHarness:
|
||||
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
|
||||
cwd = str(Path(self.config.cwd or Path.cwd()).resolve())
|
||||
runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd
|
||||
self._cwd = cwd
|
||||
env = dict(self.config.env)
|
||||
if self.config.session_root is not None:
|
||||
env["DSH_SESSION_ROOT"] = self.config.session_root
|
||||
@@ -73,8 +71,6 @@ class DeepSeekHarness:
|
||||
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
|
||||
@@ -95,10 +91,8 @@ class DeepSeekHarness:
|
||||
return
|
||||
self._client.start()
|
||||
self._client.initialize(
|
||||
cwd=self.config.cwd or str(Path.cwd()),
|
||||
cwd=self._cwd,
|
||||
model=self.config.model,
|
||||
session_root=self.config.session_root,
|
||||
system_prompt=self.config.system_prompt,
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
@@ -115,10 +109,9 @@ class DeepSeekHarness:
|
||||
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)
|
||||
return self.start_session(session_id).run(input, on_notification=on_notification)
|
||||
|
||||
|
||||
class Session:
|
||||
@@ -130,7 +123,6 @@ class Session:
|
||||
self,
|
||||
input: str | list[JsonObject],
|
||||
*,
|
||||
profile: str | None = None,
|
||||
on_notification: Callable[[Notification], None] | None = None,
|
||||
) -> TurnResult:
|
||||
content_blocks = normalize_input(input)
|
||||
@@ -156,7 +148,6 @@ class Session:
|
||||
self.harness.client.session_prompt(
|
||||
self.id,
|
||||
content_blocks,
|
||||
profile=profile,
|
||||
on_notification=collect,
|
||||
notification_subscription=subscription,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Callable, Literal, TypeAlias, TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
@@ -31,8 +32,6 @@ class HarnessConfig:
|
||||
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:
|
||||
@@ -75,7 +74,7 @@ class HarnessClient:
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=self.config.cwd,
|
||||
cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()),
|
||||
env=env,
|
||||
bufsize=1,
|
||||
)
|
||||
@@ -90,18 +89,22 @@ class HarnessClient:
|
||||
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:
|
||||
if proc.poll() is None:
|
||||
try:
|
||||
proc.terminate()
|
||||
proc.wait(timeout=2)
|
||||
except Exception:
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
try:
|
||||
proc.wait(timeout=self.config.shutdown_timeout_seconds)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait()
|
||||
self._proc = None
|
||||
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)
|
||||
@@ -113,35 +116,26 @@ class HarnessClient:
|
||||
*,
|
||||
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,
|
||||
"cwd": str(Path(cwd).resolve()),
|
||||
"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)
|
||||
try:
|
||||
return self.request("initialize", payload, response_model=InitializeResponse)
|
||||
except BaseException:
|
||||
self.close()
|
||||
raise
|
||||
|
||||
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,
|
||||
|
||||
@@ -66,7 +66,6 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None:
|
||||
runtime_cwd=str(repo_root),
|
||||
session_root=str(session_root),
|
||||
cordis=str(bundled_default_config_path()),
|
||||
system_prompt="You are running a Python SDK smoke test.",
|
||||
launch_args_override=("node", "--import", "tsx", str(runtime_entry)),
|
||||
env={
|
||||
"DEEPSEEK_BASE_URL": base_url,
|
||||
@@ -78,7 +77,6 @@ def run_smoke(repo_root: Path, keep_sessions: bool) -> None:
|
||||
result = harness.run(
|
||||
"Please reply with a short confirmation and do not call tools.",
|
||||
session_id="sdk-smoke-main",
|
||||
profile="build",
|
||||
)
|
||||
print(f"turn_status={result.status}")
|
||||
print(f"final_response={result.final_response}")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import inspect
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
@@ -121,6 +122,45 @@ for line in sys.stdin:
|
||||
assert seen == ["subagent.started", "session.finished"]
|
||||
|
||||
|
||||
def test_relative_cwd_is_absolute_in_process_environment_and_wire(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
script = tmp_path / "capture_cwd.py"
|
||||
capture = tmp_path / "cwd.json"
|
||||
script.write_text(
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "initialize":
|
||||
json.dump({"process": os.getcwd(), "environment": os.environ.get("DSH_CWD"), "wire": msg["params"]["cwd"]}, open(os.environ["CAPTURE"], "w"))
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
|
||||
elif msg.get("method") == "shutdown":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
|
||||
break
|
||||
""".strip()
|
||||
)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
with DeepSeekHarness(
|
||||
cwd=".",
|
||||
runtime_cwd=".",
|
||||
launch_args_override=(sys.executable, str(script)),
|
||||
env={"CAPTURE": str(capture)},
|
||||
):
|
||||
pass
|
||||
|
||||
expected = str(tmp_path.resolve())
|
||||
assert json.loads(capture.read_text()) == {
|
||||
"process": expected,
|
||||
"environment": expected,
|
||||
"wire": expected,
|
||||
}
|
||||
|
||||
|
||||
def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None:
|
||||
script = tmp_path / "fake_runtime.py"
|
||||
script.write_text(
|
||||
@@ -293,7 +333,7 @@ for line in sys.stdin:
|
||||
init = client.initialize(cwd="/workspace", model="dsagent")
|
||||
assert init.serverInfo.name == "fake-dsh"
|
||||
|
||||
client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build")
|
||||
client.session_prompt("main", [{"type": "text", "text": "fix it"}])
|
||||
notification = client.next_notification()
|
||||
assert notification.method == "llm/request"
|
||||
assert notification.payload["requestId"] == "req-1"
|
||||
@@ -339,7 +379,7 @@ for line in sys.stdin:
|
||||
with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client:
|
||||
client.initialize(cwd="/workspace", model="dsagent")
|
||||
with pytest.raises(ValueError):
|
||||
client.session_prompt("main", [{"type": "text", "text": "fix it"}], profile="build")
|
||||
client.session_prompt("main", [{"type": "text", "text": "fix it"}])
|
||||
|
||||
|
||||
def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None:
|
||||
@@ -434,9 +474,12 @@ def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) -
|
||||
script.write_text(
|
||||
"""
|
||||
import json
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
|
||||
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "initialize":
|
||||
@@ -453,10 +496,56 @@ for line in sys.stdin:
|
||||
)
|
||||
)
|
||||
client.start()
|
||||
proc = client._proc
|
||||
assert proc is not None
|
||||
client.initialize(cwd="/workspace", model="dsagent")
|
||||
start = time.monotonic()
|
||||
client.close()
|
||||
assert time.monotonic() - start < 2
|
||||
assert proc.poll() is not None
|
||||
assert client._proc is None
|
||||
|
||||
|
||||
def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None:
|
||||
script = tmp_path / "rejecting_runtime.py"
|
||||
script.write_text(
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
|
||||
for line in sys.stdin:
|
||||
msg = json.loads(line)
|
||||
if msg.get("method") == "initialize":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True)
|
||||
elif msg.get("method") == "shutdown":
|
||||
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
|
||||
break
|
||||
""".strip()
|
||||
)
|
||||
|
||||
client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script))))
|
||||
client.start()
|
||||
proc = client._proc
|
||||
assert proc is not None
|
||||
|
||||
with pytest.raises(Exception, match="bad initialize"):
|
||||
client.initialize(cwd=".", model="dsagent")
|
||||
|
||||
assert proc.wait(timeout=1) is not None
|
||||
assert client._proc is None
|
||||
|
||||
|
||||
def test_public_signatures_omit_unsupported_wire_parameters() -> None:
|
||||
from deepseek_harness import DeepSeekHarnessConfig, Session
|
||||
|
||||
assert "session_root" not in inspect.signature(HarnessClient.initialize).parameters
|
||||
assert "system_prompt" not in inspect.signature(HarnessClient.initialize).parameters
|
||||
assert "profile" not in inspect.signature(HarnessClient.session_prompt).parameters
|
||||
assert "profile" not in inspect.signature(DeepSeekHarness.run).parameters
|
||||
assert "profile" not in inspect.signature(Session.run).parameters
|
||||
assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__
|
||||
assert "client_name" not in HarnessConfig.__dataclass_fields__
|
||||
assert "client_version" not in HarnessConfig.__dataclass_fields__
|
||||
|
||||
|
||||
def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None:
|
||||
|
||||
Reference in New Issue
Block a user