feat(sdk): add explicit persisted session resume

This commit is contained in:
fz committed 2026-10-10 13:08:00 +08:00
1 parent 56bdf15b4f
commit 0bec9479ae
26 files changed
+495 -25

No files matched your search

+2 -2
View File
@@ -12,8 +12,8 @@
en: 9a069c1a7cbb0779
zh: 7f625a802cd1e9ea
/deepseek-harness-python-sdk/customize-plugins:
en: d32ee602f121e6af
zh: fdea4487688e5774
en: a6f2fcfa7ba8a050
zh: 60fdca6dc0594c79
/deepseek-harness-python-sdk/results-and-notifications:
en: 173d2dc1ce63fe2d
zh: 5354e18c59f20878
+8
View File
@@ -79,6 +79,14 @@ with DeepSeekHarness(
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides only a platform-selected persistent shell, local execution, and JSONL sessions; filesystem tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles.
`harness.resume_session(saved_session_id)` restores persisted history and returns a `Session` ready for `run()`. Save the original session id and reuse its `dsh_home` and workspace. Restoration preserves the stored directory, repairs an interrupted final turn, and sends no new input. Missing, unreadable, incompatible, or occupied sessions raise without creating a replacement. `start_session(id)` only opens a lazy handle; it does not request persisted restoration. The low-level client also exposes `resume_session(id)`, returning the restored identity. This API does not restore shell, browser, or other external processes.
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## Results and notifications
`Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, finish_reason, events, notifications)`. `final_response` is the last committed root-session assistant text in the interval. `finish_reason` is the `kind` of the last root-session `turn/end`, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. A `turn/end` without a string `data.reason.kind` violates the protocol and raises `SdkProtocolError`.
+8
View File
@@ -79,6 +79,14 @@ with DeepSeekHarness(
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它只提供按平台选择的持久 shell、本地执行与 JSONL 会话;文件系统工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。
`harness.resume_session(saved_session_id)` 恢复持久化历史,并返回可调用 `run()` 的 `Session`。请保存原会话 id,并沿用其 `dsh_home` 和工作区。恢复会保留存储的目录、修复最后一个被中断的轮次,且不发送新输入。会话缺失、不可读、不兼容或被占用时会抛出异常,不创建替代会话。`start_session(id)` 只打开惰性句柄,不会请求持久化恢复。底层客户端也提供 `resume_session(id)`,返回恢复后的标识。此 API 不恢复 shell、浏览器或其他外部进程。
```py
with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
session = harness.resume_session(saved_session_id)
result = session.run("Continue the task.")
```
## 结果与通知
`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。
+9
View File
@@ -121,6 +121,15 @@ class DeepSeekHarness:
self.start()
return Session(self, session_id or f"session-{uuid.uuid4().hex}")
def resume_session(self, session_id: str) -> "Session":
"""Restore persisted history from the configured Harness home without sending input.
Missing, unreadable, or occupied sessions raise instead of creating a new session.
"""
self.start()
restored_id = self._client.resume_session(session_id)
return Session(self, restored_id)
def run(
self,
input: str | list[JsonObject],
+16 -1
View File
@@ -14,7 +14,7 @@ from typing import Callable, TypeAlias, TypeVar
from pydantic import BaseModel
from .errors import JsonRpcError, TransportClosedError
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification
ModelT = TypeVar("ModelT", bound=BaseModel)
@@ -188,6 +188,17 @@ class HarnessClient:
)
return response.messageId
def resume_session(self, session_id: str) -> str:
"""Restore a persisted session without sending a prompt; propagate restoration failures."""
response = self.request(
"session/resume",
{"sessionId": session_id},
response_model=_SessionResumeResponse,
)
if response.sessionId != session_id:
raise SdkProtocolError("session/resume returned an unexpected session id")
return response.sessionId
def get_working_directory(self, session_id: str) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
response = self.request(
@@ -599,6 +610,10 @@ class _SessionPromptResponse(BaseModel):
messageId: str
class _SessionResumeResponse(BaseModel):
sessionId: str
class _WorkingDirectoryResponse(BaseModel):
cwd: str
+61
View File
@@ -0,0 +1,61 @@
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
from deepseek_harness import DeepSeekHarness, SdkProtocolError
from deepseek_harness.errors import JsonRpcError
@pytest.mark.parametrize("outcome", ["restored", "missing", "wrong-id"])
def test_resume_checks_identity_and_propagates_failure(tmp_path: Path, outcome: str) -> None:
script = tmp_path / "runtime.py"
requests = tmp_path / "requests.jsonl"
script.write_text(
"""
import json
import os
import sys
for line in sys.stdin:
frame = json.loads(line)
with open(os.environ["REQUESTS"], "a") as log:
log.write(line)
method = frame["method"]
response = {"jsonrpc": "2.0", "id": frame["id"]}
if method == "initialize":
response["result"] = {"serverInfo": {"name": "fake-runtime"}}
elif method == "session/resume":
outcome = os.environ["OUTCOME"]
if outcome == "missing":
response["error"] = {"code": -32603, "message": "session not found"}
else:
response["result"] = {"sessionId": "saved" if outcome == "restored" else "wrong"}
else:
response["result"] = {}
print(json.dumps(response), flush=True)
if method == "shutdown":
break
""",
encoding="utf-8",
)
with DeepSeekHarness(
_launch_args=(sys.executable, str(script)),
env={"OUTCOME": outcome, "REQUESTS": str(requests)},
) as harness:
if outcome == "restored":
session = harness.resume_session("saved")
assert session.id == "saved"
assert session.harness is harness
elif outcome == "missing":
with pytest.raises(JsonRpcError, match="session not found"):
harness.resume_session("saved")
else:
with pytest.raises(SdkProtocolError, match="unexpected session id"):
harness.resume_session("saved")
frames = [json.loads(line) for line in requests.read_text(encoding="utf-8").splitlines()]
assert [frame["method"] for frame in frames] == ["initialize", "session/resume", "shutdown"]
assert frames[1]["params"] == {"sessionId": "saved"}