api.py 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. from __future__ import annotations
  2. import uuid
  3. from dataclasses import dataclass, field
  4. from pathlib import Path
  5. from typing import Callable
  6. from .client import HarnessClient, HarnessConfig
  7. from .errors import SdkProtocolError
  8. from .models import JsonObject, Notification
  9. @dataclass(slots=True)
  10. class DeepSeekHarnessConfig:
  11. """Configuration for launching the local DeepSeek Harness SDK runtime.
  12. The runtime inherits the caller's environment by default, so existing
  13. DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to
  14. intentionally override or inject variables for a subprocess.
  15. """
  16. provider: str = "deepseek-official"
  17. model: str = "deepseek-v4-flash"
  18. max_tokens: int | None = None
  19. cwd: str | None = None
  20. runtime_cwd: str | None = None
  21. session_root: str | None = None
  22. cordis: str | None = None
  23. env: dict[str, str] = field(default_factory=dict)
  24. runtime_bin: str | None = None
  25. launch_args_override: tuple[str, ...] | None = None
  26. request_timeout_seconds: float | None = None
  27. shutdown_timeout_seconds: float | None = 1.0
  28. base_url: str | None = None
  29. api_key: str | None = None
  30. @dataclass(slots=True)
  31. class RunResult:
  32. session_id: str
  33. final_response: str
  34. finish_reason: str | None
  35. events: list[JsonObject]
  36. notifications: list[Notification]
  37. session_root: str | None = None
  38. class DeepSeekHarness:
  39. """Reusable synchronous SDK for running DeepSeek Harness agent turns.
  40. The runtime subprocess starts lazily and remains owned by this instance
  41. across calls to :meth:`run`. Use the instance as a context manager, or call
  42. :meth:`close` explicitly when finished, so the subprocess is always reaped.
  43. """
  44. def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None:
  45. if config is not None and kwargs:
  46. raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both")
  47. self.config = config or DeepSeekHarnessConfig(**kwargs)
  48. cwd = str(Path(self.config.cwd or Path.cwd()).resolve())
  49. runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd
  50. self._cwd = cwd
  51. env = dict(self.config.env)
  52. if self.config.session_root is not None:
  53. env["DSH_SESSION_ROOT"] = self.config.session_root
  54. if self.config.cordis is not None:
  55. env["DSH_CORDIS_CONFIG"] = self.config.cordis
  56. env["DSH_CWD"] = cwd
  57. if self.config.base_url is not None:
  58. env["DEEPSEEK_BASE_URL"] = self.config.base_url
  59. if self.config.api_key is not None:
  60. env["DEEPSEEK_API_KEY"] = self.config.api_key
  61. self._client = HarnessClient(
  62. HarnessConfig(
  63. runtime_bin=self.config.runtime_bin,
  64. launch_args_override=self.config.launch_args_override,
  65. cwd=runtime_cwd,
  66. env=env,
  67. request_timeout_seconds=self.config.request_timeout_seconds,
  68. shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
  69. )
  70. )
  71. self._initialized = False
  72. def __enter__(self) -> "DeepSeekHarness":
  73. self.start()
  74. return self
  75. def __exit__(self, _exc_type, _exc, _tb) -> None:
  76. self.close()
  77. @property
  78. def client(self) -> HarnessClient:
  79. return self._client
  80. def start(self) -> None:
  81. if self._initialized:
  82. return
  83. self._client.start()
  84. self._client.initialize(
  85. cwd=self._cwd,
  86. provider=self.config.provider,
  87. model=self.config.model,
  88. max_tokens=self.config.max_tokens,
  89. )
  90. self._initialized = True
  91. def close(self) -> None:
  92. self._client.close()
  93. self._initialized = False
  94. def start_session(self, session_id: str | None = None) -> "Session":
  95. self.start()
  96. return Session(self, session_id or f"session-{uuid.uuid4().hex}")
  97. def run(
  98. self,
  99. input: str | list[JsonObject],
  100. *,
  101. session_id: str | None = None,
  102. on_notification: Callable[[Notification], None] | None = None,
  103. ) -> RunResult:
  104. return self.start_session(session_id).run(input, on_notification=on_notification)
  105. class Session:
  106. def __init__(self, harness: DeepSeekHarness, session_id: str) -> None:
  107. self.harness = harness
  108. self.id = session_id
  109. def run(
  110. self,
  111. input: str | list[JsonObject],
  112. *,
  113. on_notification: Callable[[Notification], None] | None = None,
  114. ) -> RunResult:
  115. content_blocks = normalize_input(input)
  116. notifications: list[Notification] = []
  117. events: list[JsonObject] = []
  118. def collect(notification: Notification) -> None:
  119. notifications.append(notification)
  120. if on_notification is not None:
  121. on_notification(notification)
  122. if (
  123. notification.method == "session.event"
  124. and notification.payload.get("sessionId") == self.id
  125. ):
  126. event = notification.payload.get("event")
  127. if isinstance(event, dict):
  128. events.append(event)
  129. with self.harness.client.subscribe_session_notifications(self.id) as subscription:
  130. message_id = self.harness.client.session_prompt(
  131. self.id,
  132. content_blocks,
  133. notification_subscription=subscription,
  134. )
  135. received = False
  136. while True:
  137. notification = subscription.next()
  138. if not received:
  139. if not _is_inbox_receipt(notification, self.id, message_id):
  140. continue
  141. received = True
  142. collect(notification)
  143. if (
  144. notification.method == "session.status"
  145. and notification.payload.get("sessionId") == self.id
  146. and notification.payload.get("status") == "idle"
  147. ):
  148. break
  149. return RunResult(
  150. session_id=self.id,
  151. final_response=final_response(events),
  152. finish_reason=finish_reason(events),
  153. events=events,
  154. notifications=notifications,
  155. session_root=self.harness.config.session_root,
  156. )
  157. def _is_inbox_receipt(notification: Notification, session_id: str, message_id: str) -> bool:
  158. if notification.method != "session.event" or notification.payload.get("sessionId") != session_id:
  159. return False
  160. event = notification.payload.get("event")
  161. if not isinstance(event, dict) or event.get("type") != "agent/inbox/spliced":
  162. return False
  163. data = event.get("data")
  164. inserted = data.get("inserted") if isinstance(data, dict) else None
  165. return isinstance(inserted, list) and any(
  166. isinstance(message, dict) and message.get("id") == message_id for message in inserted
  167. )
  168. def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]:
  169. if isinstance(input, str):
  170. return [{"type": "text", "text": input}]
  171. return input
  172. def final_response(events: list[JsonObject]) -> str:
  173. for event in reversed(events):
  174. if event.get("type") != "assistant/message":
  175. continue
  176. data = event.get("data")
  177. if not isinstance(data, dict):
  178. continue
  179. message = data.get("message")
  180. content_owner = message if isinstance(message, dict) else data
  181. content = content_owner.get("content")
  182. if not isinstance(content, list):
  183. continue
  184. parts: list[str] = []
  185. for block in content:
  186. if isinstance(block, dict) and block.get("type") == "text":
  187. parts.append(str(block.get("text") or ""))
  188. return "".join(parts)
  189. return ""
  190. def finish_reason(events: list[JsonObject]) -> str | None:
  191. """Return the last turn-ending kind.
  192. The input must contain root-session events from one owned run interval.
  193. Raises:
  194. SdkProtocolError: The last ``turn/end`` has no string reason kind.
  195. """
  196. for event in reversed(events):
  197. if event.get("type") != "turn/end":
  198. continue
  199. data = event.get("data")
  200. reason = data.get("reason") if isinstance(data, dict) else None
  201. kind = reason.get("kind") if isinstance(reason, dict) else None
  202. if not isinstance(kind, str):
  203. raise SdkProtocolError("turn/end event requires a string data.reason.kind")
  204. return kind
  205. return None