feat(sdk): bridge internal approvals to stdio clients
This commit is contained in:
1 parent
147fee4186
commit
5d3dd1bbb0
41 files changed
+574
-18
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user