fix(sdk): align builtin routing and query error handling
This commit is contained in:
1 parent
2b9badbc37
commit
ae334bd17e
44 files changed
+455
-178
No files matched your search
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user