| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- from __future__ import annotations
- import uuid
- from dataclasses import dataclass, field
- from pathlib import Path
- from typing import Callable
- from .client import HarnessClient, HarnessConfig
- from .errors import SdkProtocolError
- from .models import JsonObject, Notification
- @dataclass(slots=True)
- class DeepSeekHarnessConfig:
- """Configuration for launching the local DeepSeek Harness SDK runtime.
- The runtime inherits the caller's environment by default, so existing
- DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to
- intentionally override or inject variables for a subprocess.
- """
- provider: str = "deepseek-official"
- model: str = "deepseek-v4-flash"
- max_tokens: int | None = None
- cwd: str | None = None
- runtime_cwd: str | None = None
- session_root: str | None = None
- cordis: str | None = None
- env: dict[str, str] = field(default_factory=dict)
- runtime_bin: str | None = None
- launch_args_override: tuple[str, ...] | None = None
- request_timeout_seconds: float | None = None
- shutdown_timeout_seconds: float | None = 1.0
- base_url: str | None = None
- api_key: str | None = None
- @dataclass(slots=True)
- class RunResult:
- session_id: str
- final_response: str
- finish_reason: str | None
- events: list[JsonObject]
- notifications: list[Notification]
- session_root: str | None = None
- class DeepSeekHarness:
- """Reusable synchronous SDK for running DeepSeek Harness agent turns.
- The runtime subprocess starts lazily and remains owned by this instance
- across calls to :meth:`run`. Use the instance as a context manager, or call
- :meth:`close` explicitly when finished, so the subprocess is always reaped.
- """
- def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None:
- if config is not None and kwargs:
- raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both")
- self.config = config or DeepSeekHarnessConfig(**kwargs)
- cwd = str(Path(self.config.cwd or Path.cwd()).resolve())
- runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd
- self._cwd = cwd
- env = dict(self.config.env)
- if self.config.session_root is not None:
- env["DSH_SESSION_ROOT"] = self.config.session_root
- if self.config.cordis is not None:
- env["DSH_CORDIS_CONFIG"] = self.config.cordis
- env["DSH_CWD"] = cwd
- if self.config.base_url is not None:
- env["DEEPSEEK_BASE_URL"] = self.config.base_url
- if self.config.api_key is not None:
- env["DEEPSEEK_API_KEY"] = self.config.api_key
- self._client = HarnessClient(
- HarnessConfig(
- runtime_bin=self.config.runtime_bin,
- launch_args_override=self.config.launch_args_override,
- cwd=runtime_cwd,
- env=env,
- request_timeout_seconds=self.config.request_timeout_seconds,
- shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
- )
- )
- self._initialized = False
- def __enter__(self) -> "DeepSeekHarness":
- self.start()
- return self
- def __exit__(self, _exc_type, _exc, _tb) -> None:
- self.close()
- @property
- def client(self) -> HarnessClient:
- return self._client
- def start(self) -> None:
- if self._initialized:
- return
- self._client.start()
- self._client.initialize(
- cwd=self._cwd,
- provider=self.config.provider,
- model=self.config.model,
- max_tokens=self.config.max_tokens,
- )
- self._initialized = True
- def close(self) -> None:
- self._client.close()
- self._initialized = False
- def start_session(self, session_id: str | None = None) -> "Session":
- self.start()
- return Session(self, session_id or f"session-{uuid.uuid4().hex}")
- def run(
- self,
- input: str | list[JsonObject],
- *,
- session_id: str | None = None,
- on_notification: Callable[[Notification], None] | None = None,
- ) -> RunResult:
- return self.start_session(session_id).run(input, on_notification=on_notification)
- class Session:
- def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
- self.harness = harness
- self.id = session_id
- def run(
- self,
- input: str | list[JsonObject],
- *,
- on_notification: Callable[[Notification], None] | None = None,
- ) -> RunResult:
- content_blocks = normalize_input(input)
- notifications: list[Notification] = []
- events: list[JsonObject] = []
- def collect(notification: Notification) -> None:
- notifications.append(notification)
- if on_notification is not None:
- on_notification(notification)
- if (
- notification.method == "session.event"
- and notification.payload.get("sessionId") == self.id
- ):
- event = notification.payload.get("event")
- if isinstance(event, dict):
- events.append(event)
- with self.harness.client.subscribe_session_notifications(self.id) as subscription:
- message_id = self.harness.client.session_prompt(
- self.id,
- content_blocks,
- notification_subscription=subscription,
- )
- received = False
- while True:
- notification = subscription.next()
- if not received:
- if not _is_inbox_receipt(notification, self.id, message_id):
- continue
- received = True
- collect(notification)
- if (
- notification.method == "session.status"
- and notification.payload.get("sessionId") == self.id
- and notification.payload.get("status") == "idle"
- ):
- break
- return RunResult(
- session_id=self.id,
- final_response=final_response(events),
- finish_reason=finish_reason(events),
- events=events,
- notifications=notifications,
- session_root=self.harness.config.session_root,
- )
- def _is_inbox_receipt(notification: Notification, session_id: str, message_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") != "agent/inbox/spliced":
- return False
- data = event.get("data")
- inserted = data.get("inserted") if isinstance(data, dict) else None
- return isinstance(inserted, list) and any(
- isinstance(message, dict) and message.get("id") == message_id for message in inserted
- )
- def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
- if isinstance(input, str):
- return [{"type": "text", "text": input}]
- return input
- def final_response(events: list[JsonObject]) -> str:
- for event in reversed(events):
- if event.get("type") != "assistant/message":
- continue
- data = event.get("data")
- if not isinstance(data, dict):
- continue
- message = data.get("message")
- content_owner = message if isinstance(message, dict) else data
- content = content_owner.get("content")
- if not isinstance(content, list):
- continue
- parts: list[str] = []
- for block in content:
- if isinstance(block, dict) and block.get("type") == "text":
- parts.append(str(block.get("text") or ""))
- return "".join(parts)
- return ""
- def finish_reason(events: list[JsonObject]) -> str | None:
- """Return the last turn-ending kind.
- The input must contain root-session events from one owned run interval.
- Raises:
- SdkProtocolError: The last ``turn/end`` has no string reason kind.
- """
- for event in reversed(events):
- if event.get("type") != "turn/end":
- continue
- data = event.get("data")
- reason = data.get("reason") if isinstance(data, dict) else None
- kind = reason.get("kind") if isinstance(reason, dict) else None
- if not isinstance(kind, str):
- raise SdkProtocolError("turn/end event requires a string data.reason.kind")
- return kind
- return None
|