feat(sdk): support compact through unified query input

This commit is contained in:
LogosVeritas committed 2026-10-25 21:55:27 +08:00
1 parent 04b3164a27
commit 5cdd2aebed
48 files changed
+3011 -99

No files matched your search

+6 -1
View File
@@ -2,7 +2,8 @@ from .api import DeepSeekHarness, DeepSeekHarnessConfig, RunResult, Session
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
from .errors import SdkProtocolError
from .models import (
ApprovalRequestParams, ApprovalRequestResult, IncomingRequest, InitializeResponse,
ApprovalRequestParams, ApprovalRequestResult, CommandErrorResult, CommandExecution,
CommandResult, CommandSuccessResult, IncomingRequest, InitializeResponse,
JsonObject, Notification, ServerInfo, UserQuestionAnswer, UserQuestionAnswerItem, UserQuestionRequest,
)
@@ -11,6 +12,10 @@ __all__ = [
"DeepSeekHarnessConfig",
"Session",
"RunResult",
"CommandExecution",
"CommandResult",
"CommandSuccessResult",
"CommandErrorResult",
"HarnessClient",
"HarnessConfig",
"UserQuestionHandler",
+159 -4
View File
@@ -1,13 +1,16 @@
from __future__ import annotations
import uuid
import threading
from collections import deque
from collections.abc import Iterator
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
from typing import Callable, Literal
from .client import HarnessClient, HarnessConfig, UserQuestionHandler
from .errors import SdkProtocolError
from .models import JsonObject, Notification
from .client import HarnessClient, HarnessConfig, NotificationSubscription, UserQuestionHandler, _PendingRequest
from .errors import SdkProtocolError, TransportClosedError
from .models import CommandExecution, JsonObject, Notification
@dataclass(slots=True)
@@ -48,6 +51,113 @@ class RunResult:
notifications: list[Notification]
class _QueryResponseIterator(Iterator[Notification | RunResult | CommandExecution]):
def __init__(
self, client: HarnessClient, session_id: str, request: _PendingRequest,
subscription: NotificationSubscription,
) -> None:
self._client = client
self._session_id = session_id
self._request = request
self._subscription = subscription
self._lock = threading.Lock()
self._closed = False
self._released = False
self._routed = False
self._received = False
self._message_id = ""
self._notifications: list[Notification] = []
self._events: list[JsonObject] = []
self._buffer: deque[Notification] = deque()
self._terminal: RunResult | CommandExecution | None = None
def _release(self, error: BaseException | None = None) -> None:
with self._lock:
if self._released:
return
self._released = True
self._client._discard_request(self._request, error)
if error is None:
self._subscription.close()
else:
self._subscription._cancel(error)
def close(self) -> None:
"""Stop observing this query; the runtime operation is not cancelled."""
with self._lock:
if self._closed:
return
self._closed = True
self._release(TransportClosedError("query response iterator closed"))
def _ensure_open(self) -> None:
if self._closed:
raise StopIteration
def __next__(self) -> Notification | RunResult | CommandExecution:
try:
self._ensure_open()
if not self._routed:
response = self._client._wait_request(self._request)
self._ensure_open()
self._routed = True
if not isinstance(response, dict):
raise SdkProtocolError("session/query response must be a JSON object")
if response.get("kind") == "command":
self._terminal = CommandExecution.model_validate(response.get("execution"))
self._release()
while True:
try:
notification = self._subscription._next_queued()
except TransportClosedError:
break
if notification is None:
break
self._buffer.append(notification)
if _is_command_done(notification, self._session_id, self._terminal.command_id):
break
elif response.get("kind") == "prompt" and isinstance(response.get("messageId"), str) and response["messageId"]:
self._message_id = response["messageId"]
else:
raise SdkProtocolError("session/query requires a prompt messageId or command execution result")
self._ensure_open()
if self._buffer:
return self._buffer.popleft()
if self._terminal is not None:
terminal = self._terminal
self._terminal = None
self._release()
self._closed = True
return terminal
while True:
notification = self._subscription.next()
self._ensure_open()
if not self._received:
if not _is_inbox_receipt(notification, self._session_id, self._message_id):
continue
self._received = True
self._notifications.append(notification)
if notification.method == "session.event" and notification.payload.get("sessionId") == self._session_id:
event = notification.payload.get("event")
if isinstance(event, dict):
self._events.append(event)
if (notification.method == "session.status"
and notification.payload.get("sessionId") == self._session_id
and notification.payload.get("status") == "idle"):
self._terminal = RunResult(
session_id=self._session_id, final_response=final_response(self._events),
finish_reason=finish_reason(self._events), events=self._events, notifications=self._notifications,
)
self._release()
return notification
except BaseException as error:
locally_closed = self._closed
self.close()
if locally_closed and isinstance(error, (TransportClosedError, StopIteration)):
raise StopIteration
raise
class DeepSeekHarness:
"""Reusable synchronous SDK for running DeepSeek Harness agent turns.
@@ -148,6 +258,41 @@ class Session:
def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
self.harness = harness
self.id = session_id
self._queries: deque[_QueryResponseIterator] = deque()
self._query_lock = threading.Lock()
def query(self, text: str, *, delivery: Literal["queue", "steer"] = "queue") -> None:
"""Send text for runtime prompt or command routing without waiting for the response.
Pair each call with receive_response(). Delivery selects where ordinary
prompts enter the inbox; commands retain their native execution timing.
"""
if not isinstance(text, str):
raise TypeError("query text must be a string")
if delivery not in ("queue", "steer"):
raise ValueError("query delivery must be queue or steer")
self.harness.start()
client = self.harness.client
with self._query_lock:
subscription = client.subscribe_session_notifications(self.id)
try:
request = client._begin_request("session/query", {"sessionId": self.id, "text": text, "delivery": delivery})
except BaseException:
subscription.close()
raise
self._queries.append(_QueryResponseIterator(client, self.id, request, subscription))
def receive_response(self) -> _QueryResponseIterator:
"""Claim the next query in send order and iterate its notifications and terminal result.
Prompts collect the inbox-receipt-to-idle interval used by run(); commands
finish on their RPC result. Close an unfinished iterator to release its
local observation resources without cancelling the runtime operation.
"""
with self._query_lock:
if not self._queries:
raise RuntimeError("receive_response requires a preceding query")
return self._queries.popleft()
def get_working_directory(self) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
@@ -225,6 +370,16 @@ def _is_inbox_receipt(notification: Notification, session_id: str, message_id: s
)
def _is_command_done(notification: Notification, session_id: str, command_id: str) -> bool:
if notification.method != "session.event" or notification.payload.get("sessionId") != session_id:
return False
event = notification.payload.get("event")
if not isinstance(event, dict) or event.get("type") != "command/done":
return False
data = event.get("data")
return isinstance(data, dict) and data.get("commandId") == command_id
def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
if isinstance(input, str):
return [{"type": "text", "text": input}]
+80 -26
View File
@@ -15,7 +15,7 @@ from typing import Callable, TypeAlias, TypeVar
from pydantic import BaseModel
from .errors import JsonRpcError, SdkProtocolError, TransportClosedError
from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
from .models import CommandExecution, IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification, UserQuestionAnswer, UserQuestionRequest
ModelT = TypeVar("ModelT", bound=BaseModel)
NotificationFilter: TypeAlias = Callable[[Notification], bool]
@@ -39,6 +39,14 @@ class HarnessConfig:
on_user_question: UserQuestionHandler | None = None
@dataclass(slots=True)
class _PendingRequest:
request_id: str
method: str
waiter: queue.Queue[JsonValue | BaseException]
deadline: float | None
class HarnessClient:
"""Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio."""
@@ -225,6 +233,14 @@ class HarnessClient:
response_model=_EmptyResponse,
)
def session_command(self, session_id: str, line: str) -> CommandExecution:
"""Execute an explicit command and return its handler result without waiting for a turn."""
return self.request(
"session/command",
{"sessionId": session_id, "line": line},
response_model=CommandExecution,
)
def get_working_directory(self, session_id: str) -> str:
"""Read the effective directory, recovering a missing directory to the Session origin."""
response = self.request(
@@ -324,15 +340,29 @@ class HarnessClient:
notification_filter: NotificationFilter | None = None,
notification_subscription: "NotificationSubscription | None" = None,
) -> JsonValue:
request_id = str(uuid.uuid4())
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
temp_subscription: NotificationSubscription | None = None
subscription = notification_subscription
with self._lock:
self._responses[request_id] = waiter
if on_notification is not None and subscription is None:
temp_subscription = self.subscribe_notifications(notification_filter)
subscription = temp_subscription
try:
pending = self._begin_request(method, params, timeout_seconds=timeout_seconds)
return self._wait_request(pending, on_notification=on_notification, notification_subscription=subscription)
finally:
if temp_subscription is not None:
temp_subscription.close()
def _begin_request(
self,
method: str,
params: JsonObject | None = None,
*,
timeout_seconds: float | None = None,
) -> _PendingRequest:
request_id = str(uuid.uuid4())
waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1)
with self._lock:
self._responses[request_id] = waiter
try:
message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method}
if params is not None:
@@ -341,48 +371,59 @@ class HarnessClient:
except BaseException:
with self._lock:
self._responses.pop(request_id, None)
if temp_subscription is not None:
temp_subscription.close()
raise
timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds
deadline = None if timeout is None else time.monotonic() + timeout
return _PendingRequest(request_id, method, waiter, deadline)
def _discard_request(self, pending: _PendingRequest, error: BaseException | None = None) -> None:
with self._lock:
if self._responses.get(pending.request_id) is pending.waiter:
self._responses.pop(pending.request_id)
if error is not None:
pending.waiter.put_nowait(error)
def _wait_request(
self,
pending: _PendingRequest,
*,
on_notification: Callable[[Notification], None] | None = None,
notification_subscription: "NotificationSubscription | None" = None,
) -> JsonValue:
subscription = notification_subscription
try:
while True:
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
try:
item = pending.waiter.get_nowait()
break
except queue.Empty:
pass
wait_timeout = None
if on_notification is not None:
wait_timeout = 0.05
if deadline is not None:
remaining = deadline - time.monotonic()
if pending.deadline is not None:
remaining = pending.deadline - time.monotonic()
if remaining <= 0:
with self._lock:
self._responses.pop(request_id, None)
diagnostics = self._runtime_diagnostics()
suffix = f"\n{diagnostics}" if diagnostics else ""
raise TimeoutError(
f"{method} timed out waiting for DeepSeek Harness runtime{suffix}"
f"{pending.method} timed out waiting for DeepSeek Harness runtime{suffix}"
)
wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining)
try:
item = waiter.get(timeout=wait_timeout)
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
item = pending.waiter.get(timeout=wait_timeout)
break
except queue.Empty:
continue
except BaseException:
with self._lock:
self._responses.pop(request_id, None)
if temp_subscription is not None:
temp_subscription.close()
raise
if on_notification is not None and subscription is not None:
subscription.drain(on_notification)
if isinstance(item, BaseException):
raise item
return item
finally:
if temp_subscription is not None:
temp_subscription.close()
if isinstance(item, BaseException):
raise item
return item
self._discard_request(pending)
def _write_message(self, message: JsonObject) -> None:
proc = self._proc
@@ -675,12 +716,25 @@ class NotificationSubscription:
self._closed = True
self._client._unsubscribe_notifications(self._subscription_id)
def _cancel(self, error: BaseException) -> None:
self.close()
self._notifications.put(error)
def next(self) -> Notification:
item = self._notifications.get()
if isinstance(item, BaseException):
raise item
return item
def _next_queued(self) -> Notification | None:
try:
item = self._notifications.get_nowait()
except queue.Empty:
return None
if isinstance(item, BaseException):
raise item
return item
def drain(self, on_notification: Callable[[Notification], None]) -> None:
while True:
try:
+41 -2
View File
@@ -1,9 +1,9 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal, TypeAlias
from typing import Annotated, Literal, TypeAlias
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue
from pydantic import BaseModel, Field, JsonValue as PydanticJsonValue, field_validator
JsonScalar: TypeAlias = str | int | float | bool | None
JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"]
@@ -32,6 +32,45 @@ class InitializeResponse(BaseModel):
serverInfo: ServerInfo | None = None
class CommandSuccessResult(BaseModel):
"""Successful handler result, optionally linked to a Session event."""
kind: Literal["success"]
text: str | None = None
source_event_seq: int | None = Field(default=None, alias="sourceEventSeq", strict=True, ge=0, le=2**53 - 1)
@field_validator("text", "source_event_seq", mode="before")
@classmethod
def _reject_null(cls, value: object) -> object:
if value is None:
raise ValueError("optional command result fields must be omitted instead of null")
return value
class CommandErrorResult(BaseModel):
"""A command handler's reported failure."""
kind: Literal["error"]
text: str
@field_validator("text")
@classmethod
def _non_blank_text(cls, value: str) -> str:
if not value.strip():
raise ValueError("command error text must not be blank")
return value
CommandResult: TypeAlias = Annotated[CommandSuccessResult | CommandErrorResult, Field(discriminator="kind")]
class CommandExecution(BaseModel):
"""Settled command result and the id pairing its command/run and command/done events."""
command_id: str = Field(alias="commandId", min_length=1)
result: CommandResult
class ApprovalRequestParams(BaseModel):
"""Server-to-client approval/request payload after explicit handshake opt-in."""