feat(sdk): bridge internal approvals to stdio clients

This commit is contained in:
fz committed 2026-10-19 13:02:20 +08:00
1 parent 147fee4186
commit 5d3dd1bbb0
41 files changed
+574 -18

No files matched your search

+5 -2
View File
@@ -9,8 +9,11 @@
en: ddf4dabea526cef1
zh: 6e31cab10d2f3ed8
/deepseek-harness-python-sdk/start-a-runtime:
en: 5fceaffcc35039c6
zh: 1d87e3defe7e875b
en: 1ae09bebf0f9e19b
zh: 55a0d557a2cae4d8
/deepseek-harness-python-sdk/client-approvals:
en: fc69be1beb8abff1
zh: 91b56781931a6f68
/deepseek-harness-python-sdk/customize-plugins:
en: 69a6a6745c6e89c1
zh: 85e82a4cc31e16e5
+21
View File
@@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
result = session.run("Continue the task.")
```
<a id="client-approvals"></a>
## Client approvals
This low-level example receives one approval and rejects it. Use a dedicated request-handling thread or loop for a full run: `next_request()` blocks, and the request handler must run concurrently with `session/wait` or a high-level `run()`. Set `approval_requests=True` on `HarnessClient.initialize()` or `DeepSeekHarnessConfig` only when that handler is ready. Omission preserves the runtime's answerer chain. Decisions are one-shot; invalid responses and lost connections fail closed, and cancellation discards late replies. `ApprovalRequestResult` validates response values when constructing decisions.
```py
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client:
client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official",
model="deepseek-v4-flash", approval_requests=True)
client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}])
request = client.next_request()
if request.method == "approval/request":
question = ApprovalRequestParams.model_validate(request.payload)
print(question.toolName, question.reason)
client.respond(request.id, {"outcome": "rejected"})
else:
client.respond_error(request.id, -32601, "Unsupported client request")
```
## Customize plugins
Persistent customization belongs to a `dsh` profile. Initialize the shipped SDK profile and install an external bundle with the runtime wheel's `dsh` command:
+21
View File
@@ -58,6 +58,27 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
result = session.run("Continue the task.")
```
<a id="client-approvals"></a>
## 客户端审批
此底层示例接收一次审批并拒绝。完整运行应使用专门处理请求的线程或循环:`next_request()` 会阻塞,请求处理器必须与 `session/wait` 或高层 `run()` 并行运行。仅在处理器就绪时,在 `HarnessClient.initialize()` 或 `DeepSeekHarnessConfig` 设置 `approval_requests=True`;省略时保留运行时应答链。决定只作用于单次操作;无效响应和断连会拒绝放行,取消后丢弃迟到响应。构造决定时,可用 `ApprovalRequestResult` 校验响应值。
```py
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
with HarnessClient(HarnessConfig(dsh_home="/absolute/path/to/dsh-home")) as client:
client.initialize(cwd="/absolute/path/to/workspace", provider="deepseek-official",
model="deepseek-v4-flash", approval_requests=True)
client.session_prompt("main", [{"type": "text", "text": "Run the operation requiring approval."}])
request = client.next_request()
if request.method == "approval/request":
question = ApprovalRequestParams.model_validate(request.payload)
print(question.toolName, question.reason)
client.respond(request.id, {"outcome": "rejected"})
else:
client.respond_error(request.id, -32601, "Unsupported client request")
```
## 自定义插件
持久自定义属于 `dsh` profile。使用运行时 wheel 包提供的 `dsh` 命令初始化随附的 SDK profile,并安装外部 bundle:
+3 -1
View File
@@ -1,7 +1,7 @@
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
from .client import HarnessClient, HarnessConfig
from .errors import SdkProtocolError
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
from .models import ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
__all__ = [
"DeepSeekHarness",
@@ -11,6 +11,8 @@ __all__ = [
"HarnessClient",
"HarnessConfig",
"SdkProtocolError",
"ApprovalRequestParams",
"ApprovalRequestResult",
"IncomingRequest",
"InitializeResponse",
"JsonObject",
+2
View File
@@ -23,6 +23,7 @@ class DeepSeekHarnessConfig:
model: str = "deepseek-v4-flash"
reasoning_effort: str | None = None
max_tokens: int | None = None
approval_requests: bool = False
cwd: str | None = None
runtime_cwd: str | None = None
dsh_bin: str | None = None
@@ -110,6 +111,7 @@ class DeepSeekHarness:
model=self.config.model,
reasoning_effort=self.config.reasoning_effort,
max_tokens=self.config.max_tokens,
approval_requests=self.config.approval_requests,
)
self._initialized = True
@@ -138,12 +138,21 @@ class HarnessClient:
model: str,
reasoning_effort: str | None = None,
max_tokens: int | None = None,
approval_requests: bool = False,
) -> InitializeResponse:
"""Initialize the runtime; approval_requests enables approval/request reception.
When enabled, drain next_request() concurrently with blocking waits and
return one-operation decisions through respond(). Omission preserves the
runtime's composed approval answerers.
"""
payload: JsonObject = {
"cwd": str(Path(cwd).resolve()),
"provider": provider,
"model": model,
}
if approval_requests:
payload["approvalRequests"] = True
if reasoning_effort is not None:
payload["reasoningEffort"] = reasoning_effort
if max_tokens is not None:
+16 -1
View File
@@ -1,7 +1,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TypeAlias
from typing import Literal, TypeAlias
from pydantic import BaseModel
@@ -30,3 +30,18 @@ class ServerInfo(BaseModel):
class InitializeResponse(BaseModel):
serverInfo: ServerInfo | None = None
class ApprovalRequestParams(BaseModel):
"""Server-to-client approval/request payload after explicit handshake opt-in."""
sessionId: str
toolName: str
callId: str | None = None
reason: str | None = None
class ApprovalRequestResult(BaseModel):
"""One-operation decision; allowed-once is the only grant."""
outcome: Literal["allowed-once", "rejected", "cancelled"]
+49
View File
@@ -0,0 +1,49 @@
"""Approval request decoding and explicit wire capability negotiation."""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
from pydantic import ValidationError
from deepseek_harness import ApprovalRequestParams, ApprovalRequestResult, HarnessClient
@pytest.mark.parametrize("enabled", [False, True])
def test_approval_handshake_and_response(tmp_path: Path, enabled: bool) -> None:
peer = tmp_path / "approval_peer.py"
peer.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"], "result": {"serverInfo": {"name": "test"}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "capability", "params": msg["params"]}), flush=True)
if msg["params"].get("approvalRequests"):
print(json.dumps({"jsonrpc": "2.0", "id": "ask", "method": "approval/request", "params": {"sessionId": "main", "toolName": "bash", "reason": "write"}}), flush=True)
elif msg.get("id") == "ask":
print(json.dumps({"jsonrpc": "2.0", "method": "decision", "params": msg["result"]}), flush=True)
elif msg.get("method") == "shutdown":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
break
''')
with HarnessClient(_launch_args=(sys.executable, str(peer))) as client:
client.initialize(cwd=str(tmp_path), provider="mock", model="mock", approval_requests=enabled)
payload = client.next_notification().payload
assert payload.get("approvalRequests", False) is enabled
if not enabled:
assert "approvalRequests" not in payload
return
request = client.next_request()
assert request.method == "approval/request"
question = ApprovalRequestParams.model_validate(request.payload)
assert question.toolName == "bash"
assert question.callId is None
client.respond(request.id, ApprovalRequestResult(outcome="rejected").model_dump())
assert client.next_notification().payload == {"outcome": "rejected"}
def test_approval_result_rejects_unrecognized_grants() -> None:
with pytest.raises(ValidationError):
ApprovalRequestResult(outcome="allow-always")
+52
View File
@@ -0,0 +1,52 @@
"""Python decisions control a guarded file operation through the built sdk profile."""
from __future__ import annotations
import json
import shutil
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
import pytest
from pydantic import BaseModel
from deepseek_harness import ApprovalRequestParams, HarnessClient, HarnessConfig
class EmptyResponse(BaseModel):
pass
@pytest.mark.parametrize("outcome", ["allowed-once", "rejected"])
def test_python_approval_controls_runtime_operation(tmp_path: Path, outcome: str) -> None:
repo = Path(__file__).resolve().parents[3]
node = shutil.which("node")
binary = repo / "apps/cli/lib/bin.js"
if node is None or not binary.exists():
pytest.skip("requires Node and built dsh CLI")
fixture = repo / "apps/cli/tests/profiles/sdk/fixtures/approval.mjs"
marker = tmp_path / "approved.txt"
patch = tmp_path / "approval.patch.yml"
patch.write_text("- insert:\n - id: approval-fixture\n name: " + json.dumps(str(fixture))
+ "\n config:\n stopAfterApproval: true\n path: " + json.dumps(str(marker)) + "\n")
# The client closes inside the executor lifetime, unblocking a reader even
# when a regression prevents the expected request from arriving.
with ThreadPoolExecutor(max_workers=1) as worker:
with HarnessClient(
HarnessConfig(cwd=str(repo), request_timeout_seconds=20, shutdown_timeout_seconds=20,
env={"DSH_HOME": str(tmp_path / "home"), "DSH_PERMISSION_MODE": "workspace-write",
"DSH_TELEMETRY_DISABLED": "1", "DEEPSEEK_API_KEY": "keyless-no-model-call"}),
_launch_args=(node, str(binary), "--profile", "sdk", "--patch", str(patch)),
) as client:
client.initialize(cwd=str(tmp_path), provider="deepseek-official", model="deepseek-v4-flash",
approval_requests=True)
client.session_prompt("main", [{"type": "text", "text": "Request the marker write."}])
request = worker.submit(client.next_request).result(timeout=20)
assert request.method == "approval/request"
question = ApprovalRequestParams.model_validate(request.payload)
assert question.sessionId == "main"
assert question.toolName == "fixture-write"
client.respond(request.id, {"outcome": outcome})
client.request("session/wait", {"sessionId": "main"}, response_model=EmptyResponse)
assert marker.exists() is (outcome == "allowed-once")
if marker.exists():
assert marker.read_text() == "approved\n"
+2
View File
@@ -165,6 +165,7 @@ for line in sys.stdin:
)
with DeepSeekHarness(
approval_requests=True,
model="deepseek-v4-flash",
reasoning_effort="max",
max_tokens=4096,
@@ -211,6 +212,7 @@ for line in sys.stdin:
assert dumped_env["DSH_SESSION_ROOT"] is None
assert dumped_env["DSH_CORDIS_CONFIG"] is None
assert json.loads(init_dump.read_text()) == {
"approvalRequests": True,
"cwd": str(tmp_path),
"provider": "deepseek-official",
"model": "deepseek-v4-flash",