Merge origin/master into feat/sdk-approval-rpc
This commit is contained in:
commit
b42e32dbd4
1017 files changed
+28849
-4199
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user