Merge remote-tracking branch 'origin/master' into xtr/tool-executor-plugins

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
#	packages/web/tool-web/tests/integration.spec.ts
#	pnpm-lock.yaml
#	scripts/check-workspace-constraints.spec.ts
#	scripts/check-workspace-constraints.ts
This commit is contained in:
_Kerman committed 2026-10-21 11:22:06 +08:00
commit 38b9a095f3
1000 files changed
+20425 -3924

No files matched your search

+3
View File
@@ -14,6 +14,9 @@
/deepseek-harness-python-sdk/customize-plugins:
en: 69a6a6745c6e89c1
zh: 85e82a4cc31e16e5
/deepseek-harness-python-sdk/native-user-questions:
en: d9c60c5f43755782
zh: 78c82635209ed22d
/deepseek-harness-python-sdk/results-and-notifications:
en: d4f7366c2d260639
zh: 7de99aed2b1bdd0a
+6
View File
@@ -87,6 +87,12 @@ with DeepSeekHarness(
The shipped `sdk-minimal` profile is a standalone explicit tree rather than an overlay on `dsh-base`. Select it with `profile="sdk-minimal"`; the ordinary `model` argument is the sole runtime model selection, including for model ids outside the adapter's advisory catalog. It provides a platform-selected persistent shell, `read_image`, `working_directory`, local execution, JSONL sessions, and automatic compaction. Text file tools, settings, managed credentials, telemetry, Web tools, and the full default tool roster remain available through the separate full `sdk` and `web` profiles. SDK prompt text does not execute `/compact`.
## Native user questions
Pass `on_user_question` to `DeepSeekHarnessConfig`, `DeepSeekHarness`, or `HarnessConfig` to answer the built-in `ask_user_question` tool through the native SDK protocol. The callback receives a `UserQuestionRequest` (`request_id`, `session_id`, and the original `questions`) and a `threading.Event` signalling cancellation. Return `UserQuestionAnswer` or a JSON object shaped as `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}`. Each original question id must appear exactly once. The application supplies its UI or simulated-user policy; the SDK does not choose answers.
Callbacks run on separate threads so the reader can process cancellation and other requests. They may run concurrently and may make further client requests. Release callback-owned work when the cancellation Event is set. Closing the client signals outstanding callbacks and gives their workers a shared 0.5-second join window; callbacks that ignore cancellation may continue, but their late answers cannot reach the runtime or a later process. A callback exception becomes a protocol error, and an absent callback retains existing native answerers or reports `NO_PROVIDER`.
## Results and notifications
`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)`. `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`, 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 protocol and raises `SdkProtocolError`.
+6
View File
@@ -87,6 +87,12 @@ with DeepSeekHarness(
随附的 `sdk-minimal` profile 是独立显式配置树,而不是 `dsh-base` 上的 overlay。使用 `profile="sdk-minimal"` 选择它;普通 `model` 参数是唯一运行时模型选择,也适用于不在适配器建议目录中的模型 ID。它提供按平台选择的持久 shell、`read_image`、`working_directory`、本地执行、JSONL 会话和自动压缩。文本文件工具、设置、托管凭据、遥测、Web 工具与完整默认工具清单仍由独立的完整 `sdk` 与 `web` profile 提供。SDK 提示词文本不会执行 `/compact`。
## 原生用户问答
向 `DeepSeekHarnessConfig`、`DeepSeekHarness` 或 `HarnessConfig` 传入 `on_user_question`,即可通过原生 SDK 协议回答内建的 `ask_user_question` 工具。回调接收 `UserQuestionRequest`(包含 `request_id`、`session_id` 和原始 `questions`)以及表示取消的 `threading.Event`。返回 `UserQuestionAnswer`,或形如 `{"answers": [{"id": "question-id", "selected": ["option label"], "custom": "optional free text"}]}` 的 JSON 对象。每个原问题 id 必须恰好出现一次。应用自行提供 UI 或模拟用户策略;SDK 不选择答案。
回调在独立线程执行,读取线程仍可处理取消和其他请求。回调可以并发执行,也可以再次调用客户端请求。取消 Event 置位时应释放回调拥有的工作。客户端关闭会通知所有待决回调,并给其工作线程总计 0.5 秒的 join 窗口;忽略取消的回调可能继续执行,但晚到答案不会发送到运行时或后续新进程。回调异常转为协议错误;未提供回调时保留原生回答方,或报告 `NO_PROVIDER`。
## 结果与通知
`Session.run()` 的活动区间从提示词被持久 inbox 接收时开始,到整个 agent 下一次进入空闲状态时结束,并返回 `RunResult(session_id, final_response, finish_reason, events, notifications)`。`final_response` 是该区间内根会话最后提交的助手文本。`finish_reason` 是最后一个根会话 `turn/end` 的 `kind`,例如 `completed`、`max-tokens` 或 `error`;没有轮次结束时为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 违反协议,并会抛出 `SdkProtocolError`。
+6 -2
View File
@@ -1,7 +1,7 @@
from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
from .client import HarnessClient, HarnessConfig
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
from .errors import SdkProtocolError
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest
__all__ = [
"DeepSeekHarness",
@@ -10,6 +10,10 @@ __all__ = [
"RunResult",
"HarnessClient",
"HarnessConfig",
"UserQuestionHandler",
"UserQuestionRequest",
"UserQuestionAnswer",
"UserQuestionAnswerItem",
"SdkProtocolError",
"IncomingRequest",
"InitializeResponse",
+3 -1
View File
@@ -5,7 +5,7 @@ from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from .client import HarnessClient, HarnessConfig
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
from .errors import SdkProtocolError
from .models import JsonObject, Notification
@@ -35,6 +35,7 @@ class DeepSeekHarnessConfig:
shutdown_timeout_seconds: float | None = 1.0
base_url: str | None = None
api_key: str | None = None
on_user_question: UserQuestionHandler | None = None
@dataclass(slots=True)
@@ -84,6 +85,7 @@ class DeepSeekHarness:
initialize_timeout_seconds=self.config.initialize_timeout_seconds,
request_timeout_seconds=self.config.request_timeout_seconds,
shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
on_user_question=self.config.on_user_question,
),
_launch_args=_launch_args,
)
+82 -2
View File
@@ -15,10 +15,11 @@ from typing import Callable, TypeAlias, TypeVar
from pydantic import BaseModel
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
ModelT = TypeVar("ModelT", bound=BaseModel)
NotificationFilter: TypeAlias = Callable[[Notification], bool]
UserQuestionHandler: TypeAlias = Callable[[UserQuestionRequest, threading.Event], UserQuestionAnswer | JsonObject]
@dataclass(slots=True)
@@ -34,6 +35,8 @@ class HarnessConfig:
initialize_timeout_seconds: float = 30.0
request_timeout_seconds: float | None = None
shutdown_timeout_seconds: float | None = 1.0
# Callbacks run outside the reader; observe the Event to release work on cancellation.
on_user_question: UserQuestionHandler | None = None
class HarnessClient:
@@ -60,6 +63,9 @@ class HarnessClient:
self._stderr_lines: deque[str] = deque(maxlen=400)
self._reader_thread: threading.Thread | None = None
self._stderr_thread: threading.Thread | None = None
self._question_tasks: dict[str, tuple[threading.Event, threading.Thread]] = {}
self._closing = False
self._user_question_handler: UserQuestionHandler | None = None
def __enter__(self) -> "HarnessClient":
self.start()
@@ -73,6 +79,9 @@ class HarnessClient:
return
with self._lock:
self._session_parents.clear()
self._question_tasks.clear()
self._closing = False
self._user_question_handler = self.config.on_user_question
env = os.environ.copy()
if self.config.env:
env.update(self.config.env)
@@ -93,6 +102,11 @@ class HarnessClient:
def close(self) -> None:
"""Close the runtime after a bounded opportunity to flush durable state."""
with self._lock:
self._closing = True
question_tasks = list(self._question_tasks.values())
for cancelled, _thread in question_tasks:
cancelled.set()
proc = self._proc
if proc is None:
return
@@ -129,6 +143,10 @@ class HarnessClient:
self._reader_thread.join(timeout=0.5)
if self._stderr_thread and self._stderr_thread.is_alive():
self._stderr_thread.join(timeout=0.5)
deadline = time.monotonic() + 0.5
for _cancelled, thread in question_tasks:
if thread is not threading.current_thread():
thread.join(timeout=max(0, deadline - time.monotonic()))
def initialize(
self,
@@ -148,6 +166,8 @@ class HarnessClient:
payload["reasoningEffort"] = reasoning_effort
if max_tokens is not None:
payload["maxTokens"] = max_tokens
if self._user_question_handler is not None:
payload["capabilities"] = {"userQuestions": True}
try:
return self.request(
"initialize",
@@ -407,7 +427,11 @@ class HarnessClient:
method = message.get("method")
if isinstance(msg_id, (str, int)) and isinstance(method, str):
params = message.get("params")
self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {}))
request = IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})
if method == "user-question/ask" and self._user_question_handler is not None:
self._start_user_question(request)
else:
self._requests.put(request)
return
if isinstance(msg_id, (str, int)):
with self._lock:
@@ -424,6 +448,10 @@ class HarnessClient:
params = message.get("params")
notification = Notification(method=method, payload=params if isinstance(params, dict) else {})
with self._lock:
if method == "user-question.cancelled":
task = self._question_tasks.get(str(notification.payload.get("requestId")))
if task is not None:
task[0].set()
self._record_session_relationship_locked(notification)
subscribers = list(self._notification_subscribers.items())
delivered = False
@@ -445,6 +473,8 @@ class HarnessClient:
def _fail_waiters(self, exc: BaseException) -> None:
with self._lock:
for cancelled, _thread in self._question_tasks.values():
cancelled.set()
waiters = list(self._responses.values())
self._responses.clear()
subscribers = list(self._notification_subscribers.values())
@@ -456,6 +486,56 @@ class HarnessClient:
self._notifications.put(exc)
self._requests.put(exc)
def _start_user_question(self, incoming: IncomingRequest) -> None:
"""Keep the reader free to receive cancellation, other responses, and shutdown."""
handler = self._user_question_handler
proc = self._proc
try:
request = UserQuestionRequest.model_validate(incoming.payload)
if any(not isinstance(item.get("id"), str) or not isinstance(item.get("question"), str) for item in request.questions):
raise ValueError("questions require id and question strings")
except ValueError:
self.respond_error(incoming.id, code=-32602, message="invalid user-question/ask parameters")
return
cancelled = threading.Event()
def answer() -> None:
try:
if cancelled.is_set() or handler is None:
return
result = handler(request, cancelled)
wire_result = result.model_dump(exclude_none=True) if isinstance(result, UserQuestionAnswer) else result
with self._lock:
if not cancelled.is_set() and self._proc is proc:
self.respond(incoming.id, wire_result)
except Exception as exc:
with self._lock:
if not cancelled.is_set() and self._proc is proc:
try:
self.respond_error(incoming.id, code=-32603, message=str(exc))
except TransportClosedError:
cancelled.set()
finally:
with self._lock:
current = self._question_tasks.get(request.request_id)
if current is not None and current[0] is cancelled:
self._question_tasks.pop(request.request_id, None)
thread = threading.Thread(target=answer, name="dsh-user-question", daemon=True)
with self._lock:
if self._closing:
return
if request.request_id in self._question_tasks:
self.respond_error(incoming.id, code=-32602, message="duplicate user-question requestId")
return
self._question_tasks[request.request_id] = (cancelled, thread)
try:
thread.start()
except BaseException:
self._question_tasks.pop(request.request_id, None)
cancelled.set()
raise
def _runtime_closed_error(self, reason: str) -> TransportClosedError:
diagnostics = self._runtime_diagnostics()
return TransportClosedError(f"{reason}\n{diagnostics}" if diagnostics else reason)
+23 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import TypeAlias
from pydantic import BaseModel
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
@@ -30,3 +30,25 @@ class ServerInfo(BaseModel):
class InitializeResponse(BaseModel):
serverInfo: ServerInfo | None = None
class UserQuestionRequest(BaseModel):
"""Runtime question batch; questions retain their options, detail, and presentation intent."""
request_id: str = Field(alias="requestId", min_length=1)
session_id: str = Field(alias="sessionId")
questions: list[dict[str, PydanticJsonValue]] = Field(min_length=1)
class UserQuestionAnswerItem(BaseModel):
"""Answer to one original question id, using selected labels or custom text."""
id: str
selected: list[str]
custom: str | None = None
class UserQuestionAnswer(BaseModel):
"""One answer per original question; an empty batch is not an implicit approval."""
answers: list[UserQuestionAnswerItem]
+233
View File
@@ -0,0 +1,233 @@
from __future__ import annotations
import sys
import threading
from pathlib import Path
import pytest
from pydantic import BaseModel
from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig, UserQuestionRequest
from deepseek_harness.errors import TransportClosedError
class Response(BaseModel):
answer: dict | None = None
error: dict | None = None
cancelled: bool = False
replies: int = 0
capabilities: dict | None = None
@pytest.fixture
def runtime(tmp_path: Path) -> Path:
path = tmp_path / "questions.py"
path.write_text('''
import json
import sys
pending = {}
replies = 0
capabilities = None
serial = 0
def send(frame):
print(json.dumps({'jsonrpc': '2.0', **frame}), flush=True)
for line in sys.stdin:
frame = json.loads(line)
method = frame.get('method')
params = frame.get('params') or {}
if method is None:
replies += 1
original = pending.pop(frame['id'], None)
if original is not None:
send({'id': original, 'result': {'answer': frame.get('result'), 'error': frame.get('error')}})
elif method == 'initialize':
capabilities = params.get('capabilities')
send({'id': frame['id'], 'result': {'serverInfo': {'name': 'question-peer', 'version': '1'}}})
elif method == 'ask':
serial += 1
wire_id = 'host-' + str(serial)
pending[wire_id] = frame['id']
send({'id': wire_id, 'method': 'user-question/ask', 'params': {
'requestId': params.get('requestId', wire_id), 'sessionId': 'main',
'questions': params.get('questions', [{'id': 'format', 'question': 'Which format?', 'detail': 'Plan to review',
'options': [{'label': 'CSV'}], 'intent': {'kind': 'plan-review', 'approve': 'CSV'}}])}})
elif method == 'cancel':
send({'method': 'user-question.cancelled', 'params': {'requestId': params['requestId'], 'sessionId': 'main'}})
for original in pending.values():
send({'id': original, 'result': {'cancelled': True}})
pending.clear()
send({'id': frame['id'], 'result': {}})
elif method == 'disconnect':
break
elif method == 'stats':
send({'id': frame['id'], 'result': {'replies': replies, 'capabilities': capabilities}})
elif method == 'shutdown':
send({'id': frame['id'], 'result': {}})
break
''')
return path
def config(callback=None) -> HarnessConfig:
return HarnessConfig(on_user_question=callback, request_timeout_seconds=5)
def initialize(client: HarnessClient, root: Path) -> None:
client.initialize(cwd=str(root), provider="mock", model="mock")
def test_callback_preserves_question_metadata_and_answers_without_blocking_reader(runtime: Path, tmp_path: Path) -> None:
def answer(request: UserQuestionRequest, cancelled: threading.Event):
assert request.session_id == "main"
assert request.questions[0]["intent"] == {"kind": "plan-review", "approve": "CSV"}
assert request.questions[0]["detail"] == "Plan to review"
assert not cancelled.is_set()
# Reentrant RPC proves the reader keeps processing while the host decides.
assert client.request("stats", {}, response_model=Response).capabilities == {"userQuestions": True}
return {"answers": [{"id": "format", "selected": ["CSV"]}]}
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
initialize(client, tmp_path)
result = client.request("ask", {}, response_model=Response)
assert result.answer == {"answers": [{"id": "format", "selected": ["CSV"]}]}
assert result.error is None
def test_missing_callback_does_not_advertise_question_support(runtime: Path, tmp_path: Path) -> None:
with HarnessClient(config(), _launch_args=(sys.executable, str(runtime))) as client:
initialize(client, tmp_path)
assert client.request("stats", {}, response_model=Response).capabilities is None
@pytest.mark.parametrize("action", ["cancel", "disconnect", "close"])
def test_cancellation_stops_cooperative_host_work_and_suppresses_late_answer(runtime: Path, tmp_path: Path, action: str) -> None:
entered = threading.Event()
stopped = threading.Event()
errors: list[BaseException] = []
def answer(request: UserQuestionRequest, cancelled: threading.Event):
entered.set()
assert cancelled.wait(5), "callback did not receive cancellation"
stopped.set()
return {"answers": [{"id": "format", "selected": ["late"]}]}
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
initialize(client, tmp_path)
def ask():
try:
client.request("ask", {"requestId": "pending"}, response_model=Response)
except BaseException as error:
errors.append(error)
caller = threading.Thread(target=ask, daemon=True)
caller.start()
try:
assert entered.wait(5)
with client._lock:
workers = [thread for _cancelled, thread in client._question_tasks.values()]
if action == "close":
client.close()
else:
try:
client.request(action, {"requestId": "pending"}, response_model=Response)
except TransportClosedError:
assert action == "disconnect"
assert stopped.wait(5)
for worker in workers:
worker.join(5)
assert not worker.is_alive()
caller.join(5)
assert not caller.is_alive()
if action == "cancel":
assert client.request("stats", {}, response_model=Response).replies == 0
assert errors == []
else:
assert len(errors) == 1 and isinstance(errors[0], TransportClosedError)
finally:
client.close()
caller.join(5)
def test_callback_failure_is_returned_to_runtime_and_reader_remains_usable(runtime: Path, tmp_path: Path) -> None:
def answer(_request: UserQuestionRequest, _cancelled: threading.Event):
raise ValueError("answer service unavailable")
with HarnessClient(config(answer), _launch_args=(sys.executable, str(runtime))) as client:
initialize(client, tmp_path)
result = client.request("ask", {}, response_model=Response)
assert result.error == {"code": -32603, "message": "answer service unavailable"}
assert client.request("stats", {}, response_model=Response).replies == 1
def test_high_level_config_installs_the_same_native_callback(runtime: Path, tmp_path: Path) -> None:
with DeepSeekHarness(
cwd=str(tmp_path), _launch_args=(sys.executable, str(runtime)),
on_user_question=lambda _request, _cancelled: {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]},
) as harness:
result = harness.client.request("ask", {}, response_model=Response)
assert result.answer == {"answers": [{"id": "format", "selected": [], "custom": "JSON"}]}
def test_late_callback_cannot_answer_or_remove_a_new_process_question(runtime: Path, tmp_path: Path) -> None:
old_entered = threading.Event()
release_old = threading.Event()
new_entered = threading.Event()
stopped = threading.Event()
def old_answer(_request, cancelled):
old_entered.set()
assert release_old.wait(5)
assert cancelled.is_set()
return {"answers": [{"id": "format", "selected": ["old"]}]}
def new_answer(_request, cancelled):
new_entered.set()
assert cancelled.wait(5)
stopped.set()
return {"answers": [{"id": "format", "selected": ["new"]}]}
failures = []
settings = config(old_answer)
with HarnessClient(settings, _launch_args=(sys.executable, str(runtime))) as client:
initialize(client, tmp_path)
def ask():
try:
client.request("ask", {"requestId": "reused"}, response_model=Response)
except TransportClosedError as error:
failures.append(error)
old_caller = threading.Thread(target=ask, daemon=True)
new_caller = threading.Thread(target=ask, daemon=True)
old_caller.start()
try:
assert old_entered.wait(5)
with client._lock:
old_worker = client._question_tasks["reused"][1]
client.close()
old_caller.join(5)
assert not old_caller.is_alive()
settings.on_user_question = new_answer
client.start()
initialize(client, tmp_path)
new_caller.start()
assert new_entered.wait(5)
release_old.set()
old_worker.join(5)
assert not old_worker.is_alive()
assert client.request("stats", {}, response_model=Response).replies == 0
client.request("cancel", {"requestId": "reused"}, response_model=Response)
assert stopped.wait(5)
new_caller.join(5)
assert not new_caller.is_alive()
assert len(failures) == 1
finally:
release_old.set()
client.close()
old_caller.join(5)
if new_caller.ident is not None:
new_caller.join(5)