| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204 |
- 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 .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"
- 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 TurnResult:
- session_id: str
- status: str
- final_response: str
- 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,
- ) -> TurnResult:
- 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,
- ) -> TurnResult:
- content_blocks = normalize_input(input)
- notifications: list[Notification] = []
- events: list[JsonObject] = []
- status = "error"
- finished = False
- def collect(notification: Notification) -> None:
- nonlocal finished, status
- 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)
- if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id:
- status = str(notification.payload.get("status") or "ok")
- finished = True
- with self.harness.client.subscribe_session_notifications(self.id) as subscription:
- self.harness.client.session_prompt(
- self.id,
- content_blocks,
- on_notification=collect,
- notification_subscription=subscription,
- )
- while not finished:
- notification = subscription.next()
- collect(notification)
- return TurnResult(
- session_id=self.id,
- status=status,
- final_response=final_response(events),
- events=events,
- notifications=notifications,
- session_root=self.harness.config.session_root,
- )
- 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 ""
|