fix(python): reject malformed finish reasons
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user