fix(sdk): align builtin routing and query error handling

This commit is contained in:
LogosVeritas committed 2026-10-28 15:54:34 +08:00
1 parent 2b9badbc37
commit ae334bd17e
44 files changed
+455 -178

No files matched your search

+2 -2
View File
@@ -12,8 +12,8 @@
en: ac793c588b13ce87
zh: 502015cda46de3ac
/deepseek-harness-python-sdk/run-text-and-commands:
en: 6ff1985e095b3022
zh: 1a8373d3a9b787bb
en: 4a70013e5ba565a9
zh: 00bb9b06d38f91ba
/deepseek-harness-python-sdk/client-approvals:
en: fc69be1beb8abff1
zh: 91b56781931a6f68
+2 -2
View File
@@ -69,9 +69,9 @@ Content-block arrays use the same command routing as strings. Both `session.run(
Select a model that supports image input. Submit a command and image in the existing content-block array, for example `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`; `harness.run()` accepts the same input. Plan input and goal creation or editing accept images; `/plan off`, goal controls, compact, and permission commands reject them through the [native attachment rules](../../packages/sdk/server/README.md#command-input). Native command input supports encoded raster images, not general file uploads or client-supplied attachment references.
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity.
Each call subscribes before sending and releases its subscription before returning or raising. `on_notification` receives the ordinary activity interval in wire order; command callbacks replay Session-tree notifications from subscription through their own `command/done` after the command response, so that interval can include overlapping work. Calls may overlap without consuming each other's results. Handler refusals return `CommandExecution.result.kind="error"`; routing, protocol, transport and observer failures raise. Malformed native-command responses from `run()` or `session_command()` raise `SdkProtocolError`, with the model validation error retained as the cause. A timeout or observer failure does not roll back runtime work or authorize an automatic retry. `request_timeout_seconds` bounds the protocol response wait, not later ordinary prompt activity.
The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` accepts only supported canonical English command names, without prompt fallback or attachments. Use `run()` for Chinese aliases. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
The default full `sdk` profile provides the supported native commands; `sdk-minimal` keeps unsupported input on its ordinary prompt path. `/compact` can make an auxiliary model request and completes without a normal turn or idle notification; empty history can succeed without a summary. The lower-level `HarnessClient.session_command(session_id, line)` accepts ASCII registered names or canonical English aliases of supported builtin definitions, without prompt fallback or attachments. Use `run()` for Chinese aliases. See the [compaction example](examples/README.md#compact-a-full-sdk-session).
`/permission <preset>` changes only the receiving Session. An unknown preset returns a command error without changing policy. Later input and restoration retain successful changes; other Sessions and the caller's `DeepSeekHarnessConfig` keep their own values. A running approval-policy change can inject a notice and cause another model step. See the [permission example](examples/README.md#change-session-permissions).
+2 -2
View File
@@ -69,9 +69,9 @@ with DeepSeekHarness(dsh_home="/absolute/path/to/saved-home") as harness:
先选择支持图片输入的模型。通过现有内容块数组同时提交命令和图片,例如 `session.run([{"type": "text", "text": "/plan Analyze this image."}, {"type": "image", "data": image_base64, "mimeType": "image/png"}])`;`harness.run()` 接受相同输入。计划输入和目标创建、编辑接受图片;`/plan off`、目标控制、压缩和权限命令按[原生附件规则](../../packages/sdk/server/README.zh.md#command-input)拒绝图片。原生命令输入支持编码栅格图片,不支持一般文件上传或客户端提供的附件引用。
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
每次调用在发送前订阅,并在返回或抛出异常前释放自己的订阅。`on_notification` 按线上的顺序接收普通活动区间通知;命令回调在命令响应到达后,按顺序交付从订阅时刻到自身 `command/done` 的 Session 树通知,因此该区间可能包含重叠工作的通知。调用可以重叠,不会消耗彼此的结果。处理器拒绝返回 `CommandExecution.result.kind="error"`,路由、协议、传输和观察回调失败则抛出异常。`run()` 或 `session_command()` 收到格式错误的原生命令响应时抛出 `SdkProtocolError`,并保留模型校验错误作为 cause。超时或观察回调失败不回滚运行时工作,也不授权自动重试。`request_timeout_seconds` 限制协议响应等待,不限制之后的普通提示词活动。
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 只接受受支持的英文规范命令名,不回退为提示词,也不接受附件。中文别名使用 `run()`。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
默认的完整 `sdk` profile 提供已支持的原生命令;`sdk-minimal` 将未支持的输入保留在普通提示词路径。`/compact` 可以发起辅助模型请求,其完成不需要普通 turn 或 idle 通知;空历史可以在不生成摘要时成功。底层 `HarnessClient.session_command(session_id, line)` 接受受支持内置定义的 ASCII 注册名或英文规范别名,不回退为提示词,也不接受附件。中文别名使用 `run()`。见[压缩示例](examples/README.zh.md#compact-a-full-sdk-session)。
`/permission <preset>` 只修改接收命令的 Session。未知 preset 返回命令错误,不改变策略。后续输入与恢复保留成功的修改,其他 Session 和调用方的 `DeepSeekHarnessConfig` 保留自己的值。运行中的审批策略变化可以注入通知并触发下一模型步骤。见[权限示例](examples/README.zh.md#change-session-permissions)。
+4 -17
View File
@@ -218,13 +218,9 @@ class Session:
raise ValueError("run delivery must be queue or steer")
self.harness.start()
with self.harness.client.subscribe_session_notifications(self.id) as subscription:
response = self.harness.client._request_raw("session/query", {
"sessionId": self.id, "contentBlocks": normalize_input(input), "delivery": delivery,
})
if not isinstance(response, dict):
raise SdkProtocolError("session/query response must be a JSON object")
if response.get("kind") == "command":
execution = CommandExecution.model_validate(response.get("execution"))
response = self.harness.client._query(self.id, input, delivery)
if isinstance(response, CommandExecution):
execution = response
while True:
try:
notification = subscription._next_queued()
@@ -237,10 +233,7 @@ class Session:
if _is_command_done(notification, self.id, execution.command_id):
break
return execution
if response.get("kind") != "prompt" or not isinstance(response.get("messageId"), str) or not response["messageId"]:
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
message_id = response["messageId"]
activity = _PromptActivity(self.id, message_id)
activity = _PromptActivity(self.id, response)
while True:
notification = subscription.next()
if not activity.record(notification, on_notification):
@@ -278,12 +271,6 @@ def _is_command_done(notification: Notification, session_id: str, command_id: st
return isinstance(data, dict) and data.get("commandId") == command_id
def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
if isinstance(input, str):
return [{"type": "text", "text": input}]
return input
def final_response(events: list[JsonObject]) -> str:
for event in reversed(events):
if event.get("type") != "assistant/message":
+27 -7
View File
@@ -10,9 +10,9 @@ import uuid
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, TypeAlias, TypeVar
from typing import Callable, Literal, TypeAlias, TypeVar
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
from .models import CommandExecution, IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
@@ -227,11 +227,23 @@ class HarnessClient:
def session_command(self, session_id: str, line: str) -> CommandExecution:
"""Execute an explicit command and return its handler result without waiting for a turn."""
return self.request(
"session/command",
{"sessionId": session_id, "line": line},
response_model=CommandExecution,
)
result = self._request_raw("session/command", {"sessionId": session_id, "line": line})
return _command_execution(result, "session/command")
def _query(
self, session_id: str, input: str | list[JsonObject], delivery: Literal["queue", "steer"],
) -> str | CommandExecution:
"""Return a validated prompt receipt or command result for high-level run callers."""
blocks = [{"type": "text", "text": input}] if isinstance(input, str) else input
result = self._request_raw("session/query", {
"sessionId": session_id, "contentBlocks": blocks, "delivery": delivery,
})
if isinstance(result, dict):
if result.get("kind") == "prompt" and isinstance(result.get("messageId"), str) and result["messageId"]:
return result["messageId"]
if result.get("kind") == "command":
return _command_execution(result.get("execution"), "session/query")
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
def get_working_directory(self, session_id: str) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
@@ -727,3 +739,11 @@ class _EmptyResponse(BaseModel):
def _int_or_none(value: object) -> int | None:
return value if isinstance(value, int) else None
def _command_execution(value: JsonValue, method: str) -> CommandExecution:
"""Decode native command results and report malformed data as a protocol error."""
try:
return CommandExecution.model_validate(value)
except ValidationError as error:
raise SdkProtocolError(f"{method} returned an invalid command result") from error
+3 -2
View File
@@ -9,7 +9,7 @@ import pytest
from pydantic import ValidationError
from deepseek_harness import CommandErrorResult, CommandExecution, CommandSuccessResult, DeepSeekHarness
from deepseek_harness.errors import JsonRpcError
from deepseek_harness.errors import JsonRpcError, SdkProtocolError
@pytest.fixture
@@ -125,8 +125,9 @@ def test_command_rpc_rejection_does_not_fall_back_to_prompt(peer: Path, tmp_path
])
def test_command_rejects_malformed_results(peer: Path, tmp_path: Path, response: dict) -> None:
with harness(peer, tmp_path, {"result": response}) as sdk:
with pytest.raises(ValidationError):
with pytest.raises(SdkProtocolError) as raised:
sdk.client.session_command("main", "/compact")
assert isinstance(raised.value.__cause__, ValidationError)
def test_low_level_prompt_keeps_slash_text_as_an_ordinary_prompt(peer: Path, tmp_path: Path) -> None:
+23 -1
View File
@@ -80,6 +80,8 @@ for line in sys.stdin:
"commandId": "silent", "result": {"kind": "success"}}}})
elif text == "rpc-error":
send({"id": frame["id"], "error": {"code": -32602, "message": "query rejected"}})
elif text == "custom-response":
send({"id": frame["id"], "result": json.loads(os.environ["QUERY_RESPONSE"])})
elif text == "bad-route":
send({"id": frame["id"], "result": {"kind": "unknown"}})
elif text == "bad-execution":
@@ -237,7 +239,7 @@ def test_command_result_needs_no_notifications(sdk: DeepSeekHarness) -> None:
assert_released(sdk)
@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", ValidationError)])
@pytest.mark.parametrize("text,error", [("rpc-error", JsonRpcError), ("bad-route", SdkProtocolError), ("bad-execution", SdkProtocolError)])
def test_run_reports_routing_failures_without_retry(sdk: DeepSeekHarness, text: str, error: type[Exception]) -> None:
session = sdk.start_session("main")
with pytest.raises(error):
@@ -272,3 +274,23 @@ def test_run_requires_supported_delivery(sdk: DeepSeekHarness) -> None:
session.run("ordinary", delivery="unknown")
with pytest.raises(ValueError, match="delivery"):
session.run([{"type": "text", "text": "ordinary"}], delivery="unknown")
@pytest.mark.parametrize("response", [
None, [], {"kind": "unknown"}, {"kind": "prompt"}, {"kind": "prompt", "messageId": ""},
{"kind": "prompt", "messageId": 4},
{"kind": "command", "execution": {"commandId": "c"}},
{"kind": "command", "execution": {"commandId": "c", "result": {"kind": "success", "sourceEventSeq": True}}},
])
def test_run_rejects_invalid_routing_responses_as_protocol_errors(peer: Path, tmp_path: Path, response: object) -> None:
with DeepSeekHarness(
cwd=str(tmp_path), dsh_home=str(tmp_path / "home"), request_timeout_seconds=5,
_launch_args=(sys.executable, str(peer)),
env={"REQUEST_LOG": str(tmp_path / "requests.jsonl"), "QUERY_RESPONSE": json.dumps(response)},
) as sdk:
with pytest.raises(SdkProtocolError) as raised:
sdk.start_session("main").run("custom-response")
if isinstance(response, dict) and response.get("kind") == "command":
assert isinstance(raised.value.__cause__, ValidationError)
assert_released(sdk)
assert isinstance(sdk.start_session("main").run("/compact"), CommandExecution)