Merge origin/master into feat/sdk-approval-rpc

This commit is contained in:
fz committed 2026-10-20 11:32:08 +08:00
commit b42e32dbd4
1017 files changed
+28849 -4199

No files matched your search

+9 -2
View File
@@ -1,7 +1,10 @@
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 ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo
from .models import (
ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse,
JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest,
)
__all__ = [
"DeepSeekHarness",
@@ -10,6 +13,10 @@ __all__ = [
"RunResult",
"HarnessClient",
"HarnessConfig",
"UserQuestionHandler",
"UserQuestionRequest",
"UserQuestionAnswer",
"UserQuestionAnswerItem",
"SdkProtocolError",
"ApprovalRequestParams",
"ApprovalRequestResult",
+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
approval_requests: bool = False
@@ -85,6 +86,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,
@@ -157,6 +175,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",
@@ -416,7 +436,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:
@@ -433,6 +457,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
@@ -454,6 +482,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())
@@ -465,6 +495,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)
+22 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, 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"]
@@ -45,3 +45,24 @@ class ApprovalRequestResult(BaseModel):
"""One-operation decision; allowed-once is the only grant."""
outcome: Literal["allowed-once", "rejected", "cancelled"]
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]