feat(sdk): support compact through unified query input
This commit is contained in:
1 parent
04b3164a27
commit
5cdd2aebed
48 files changed
+3011
-99
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user