api.py 6.9 KB

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