fix(python): reject malformed finish reasons

This commit is contained in:
_Kerman
2026-08-11 16:48:41 +08:00
parent 6896fd1545
commit dbbe55fdb1
10 changed files with 81 additions and 16 deletions
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write python/sdk/README.md
README.md: 366480f9a05cfeb0071146f9d5bcdcbb6f55f14c
README.zh.md: 3c5d96e27c47a85350cb6afdab4b941381d0faf1
README.md: 8cf366c27c8a604391ea85e298ba725e9987d428
README.zh.md: a9258ce9aee9bce973107b49114d4ed6e81441e4
+1 -1
View File
@@ -42,7 +42,7 @@ with DeepSeekHarness(
The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI.
`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, session_root)`. `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` in the interval, such as `completed`, `max-tokens`, or `error`, and is `None` when no turn ended. Both fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle.
`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, session_root)`. `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` in the interval, 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 runtime protocol and raises `SdkProtocolError`. Both result fields describe the owned interval rather than an output or ending causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle.
`HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves.
+1 -1
View File
@@ -39,7 +39,7 @@ with DeepSeekHarness(
[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。
`Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)``final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end``kind`,例如 `completed``max-tokens``error`;没有轮次结束时为 `None`两个字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。
`Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications, session_root)``final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是该区间内根会话最后一个 `turn/end``kind`,例如 `completed``max-tokens``error`;没有轮次结束时为 `None`缺少字符串 `data.reason.kind``turn/end` 违反运行时协议,并会抛出 `SdkProtocolError`。两个结果字段描述的都是自有活动区间,而不是因果上归属于该提示词的输出或结束原因。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。
`HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications``on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。
@@ -1,5 +1,6 @@
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
from .client import HarnessClient, HarnessConfig
from .errors import SdkProtocolError
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
__all__ = [
@@ -9,6 +10,7 @@ __all__ = [
"RunResult",
"HarnessClient",
"HarnessConfig",
"SdkProtocolError",
"IncomingRequest",
"InitializeResponse",
"JsonObject",
+11 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from typing import Callable
from .client import HarnessClient, HarnessConfig
from .errors import SdkProtocolError
from .models import JsonObject, Notification
@@ -222,12 +223,20 @@ def final_response(events: list[JsonObject]) -> str:
def finish_reason(events: list[JsonObject]) -> str | None:
"""Return the last root turn's reason kind in an owned run interval."""
"""Return the last turn-ending kind.
The input must contain root-session events from one owned run interval.
Raises:
SdkProtocolError: The last ``turn/end`` has no string reason kind.
"""
for event in reversed(events):
if event.get("type") != "turn/end":
continue
data = event.get("data")
reason = data.get("reason") if isinstance(data, dict) else None
kind = reason.get("kind") if isinstance(reason, dict) else None
return kind if isinstance(kind, str) else None
if not isinstance(kind, str):
raise SdkProtocolError("turn/end event requires a string data.reason.kind")
return kind
return None
@@ -9,6 +9,10 @@ class TransportClosedError(HarnessError):
"""Raised when the runtime subprocess exits or closes stdout."""
class SdkProtocolError(HarnessError):
"""Raised when the runtime sends data outside the SDK protocol."""
class JsonRpcError(HarnessError):
"""Raised when the runtime returns a JSON-RPC error response."""
+48 -2
View File
@@ -9,7 +9,7 @@ from pathlib import Path
import pytest
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, Notification, SdkProtocolError
def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None:
@@ -69,6 +69,17 @@ for line in sys.stdin:
},
},
}), flush=True)
print(json.dumps({
"jsonrpc": "2.0",
"method": "session.event",
"params": {
"sessionId": params["sessionId"],
"event": {
"type": "turn/end",
"data": {"turn": 2, "reason": {"kind": "max-tokens"}},
},
},
}), flush=True)
print(json.dumps({
"jsonrpc": "2.0",
"method": "session.status",
@@ -97,7 +108,7 @@ for line in sys.stdin:
result = harness.run("say hello", session_id="main")
assert result.final_response == "hello from runtime"
assert result.finish_reason == "completed"
assert result.finish_reason == "max-tokens"
assert result.events[-1]["type"] == "turn/end"
dumped_env = json.loads(env_dump.read_text())
assert dumped_env["DEEPSEEK_API_KEY"] == "env-key"
@@ -152,6 +163,41 @@ for line in sys.stdin:
assert result.finish_reason is None
def test_high_level_sdk_rejects_turn_end_without_reason_kind(tmp_path: Path) -> None:
script = tmp_path / "fake_runtime.py"
script.write_text(
"""
import json
import sys
for line in sys.stdin:
msg = json.loads(line)
method = msg.get("method")
if method == "initialize":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True)
elif method == "session/prompt":
params = msg.get("params") or {}
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "agent/inbox/spliced", "data": {"target": "next-turn", "start": 0, "inserted": [{"id": "message-1"}]}}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"messageId": "message-1"}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "turn/end", "data": {"turn": 1, "reason": {}}}}}), flush=True)
print(json.dumps({"jsonrpc": "2.0", "method": "session.status", "params": {"sessionId": params["sessionId"], "status": "idle"}}), flush=True)
elif method == "shutdown":
print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True)
break
""".strip()
)
with DeepSeekHarness(
launch_args_override=(sys.executable, str(script)),
cwd=str(tmp_path),
) as harness:
with pytest.raises(
SdkProtocolError,
match=r"turn/end event requires a string data\.reason\.kind",
):
harness.run("reject malformed turn ending", session_id="main")
def test_relative_cwd_is_absolute_in_process_environment_and_wire(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None: