smoke-python-runtime.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. #!/usr/bin/env python3
  2. """Keyless full-turn smoke for the SDK wrapper and direct NDJSON runtime use."""
  3. from __future__ import annotations
  4. import argparse
  5. import json
  6. import os
  7. import queue
  8. import subprocess
  9. import tempfile
  10. import threading
  11. import time
  12. from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
  13. from pathlib import Path
  14. from typing import Callable
  15. EXPECTED_TEXT = "runtime smoke ok"
  16. CODE_PROMPT = "Use run_code to compute the packaged worker smoke value."
  17. CODE_WORKER_TEXT = "code worker smoke ok"
  18. WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents."
  19. WORKFLOW_WORKER_TEXT = "workflow worker smoke ok"
  20. CUSTOM_CORDIS = """\
  21. - id: jsonrpc
  22. name: '@deepseek-ai/dsh-jsonrpc'
  23. - id: agent-core
  24. name: '@deepseek-ai/dsh-agent-core'
  25. config:
  26. tools:
  27. mode: both
  28. - id: sessions
  29. name: '@deepseek-ai/dsh-session-persistence-jsonl'
  30. config:
  31. root: !!js process.env.DSH_SESSION_ROOT
  32. - id: bash
  33. name: '@deepseek-ai/dsh-bash-local'
  34. config:
  35. cwd: !!js process.env.DSH_CWD
  36. - id: code-runtime
  37. name: '@deepseek-ai/dsh-code-runtime-worker'
  38. - id: subagents
  39. name: '@deepseek-ai/dsh-subagent'
  40. - id: workflow-engine
  41. name: '@deepseek-ai/dsh-workflow-workerthread'
  42. config:
  43. provider: spawn
  44. - id: workflow-tool
  45. name: '@deepseek-ai/dsh-tool-workflow'
  46. """
  47. class MockModelHandler(BaseHTTPRequestHandler):
  48. """Return deterministic text and worker-tool streaming completions."""
  49. requests: list[dict[str, object]] = []
  50. def do_POST(self) -> None:
  51. content_length = int(self.headers.get("content-length", "0"))
  52. body = json.loads(self.rfile.read(content_length))
  53. self.requests.append(body)
  54. self.send_response(200)
  55. self.send_header("content-type", "text/event-stream")
  56. self.end_headers()
  57. chunks = completion_chunks(body)
  58. for chunk in chunks:
  59. self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode())
  60. self.wfile.write(b"data: [DONE]\n\n")
  61. self.wfile.flush()
  62. def log_message(self, _format: str, *_args: object) -> None:
  63. return
  64. def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]:
  65. """Choose the next deterministic model response from request history."""
  66. messages = body.get("messages")
  67. if not isinstance(messages, list) or not messages:
  68. raise AssertionError(f"model request has no messages: {body}")
  69. latest = messages[-1]
  70. if not isinstance(latest, dict):
  71. raise AssertionError(f"model request has an invalid latest message: {body}")
  72. if latest.get("role") == "tool":
  73. tool_name = latest_tool_name(messages)
  74. tool_text = json.dumps(latest.get("content"))
  75. if "42" not in tool_text:
  76. raise AssertionError(f"{tool_name} worker returned no expected value: {latest}")
  77. if tool_name == "run_code":
  78. return text_chunks(CODE_WORKER_TEXT)
  79. if tool_name == "workflow":
  80. return text_chunks(WORKFLOW_WORKER_TEXT)
  81. raise AssertionError(f"unexpected tool follow-up: {tool_name}")
  82. prompt = message_text(latest.get("content"))
  83. if prompt == CODE_PROMPT:
  84. assert_advertised_tool(body, "run_code")
  85. return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"})
  86. if prompt == WORKFLOW_PROMPT:
  87. assert_advertised_tool(body, "workflow")
  88. return tool_call_chunks(
  89. "call-workflow-worker",
  90. "workflow",
  91. {
  92. "script": "return 6 * 7",
  93. "meta": {
  94. "name": "pkg-worker-smoke",
  95. "description": "exercise the packaged workflow worker",
  96. },
  97. },
  98. )
  99. return text_chunks(EXPECTED_TEXT)
  100. def text_chunks(text: str) -> list[dict[str, object]]:
  101. """Build a complete streaming text response."""
  102. return [
  103. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  104. {"choices": [{"delta": {"content": text}}]},
  105. {
  106. "choices": [{"delta": {"content": ""}, "finish_reason": "stop"}],
  107. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  108. },
  109. ]
  110. def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]:
  111. """Build a complete streaming function-call response."""
  112. return [
  113. {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]},
  114. {
  115. "choices": [{
  116. "delta": {
  117. "tool_calls": [{
  118. "index": 0,
  119. "id": call_id,
  120. "type": "function",
  121. "function": {"name": name, "arguments": json.dumps(arguments)},
  122. }],
  123. },
  124. }],
  125. },
  126. {
  127. "choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}],
  128. "usage": {"prompt_tokens": 3, "completion_tokens": 3},
  129. },
  130. ]
  131. def latest_tool_name(messages: list[object]) -> str:
  132. """Find the assistant tool call paired with the latest tool result."""
  133. for message in reversed(messages[:-1]):
  134. if not isinstance(message, dict):
  135. continue
  136. calls = message.get("tool_calls")
  137. if not isinstance(calls, list):
  138. continue
  139. for call in reversed(calls):
  140. if not isinstance(call, dict):
  141. continue
  142. function = call.get("function")
  143. if isinstance(function, dict) and isinstance(function.get("name"), str):
  144. return function["name"]
  145. raise AssertionError(f"tool result has no preceding assistant tool call: {messages}")
  146. def message_text(content: object) -> str:
  147. """Read OpenAI text content in either string or block-list form."""
  148. if isinstance(content, str):
  149. return content
  150. if isinstance(content, list):
  151. return "".join(
  152. block.get("text", "")
  153. for block in content
  154. if isinstance(block, dict) and isinstance(block.get("text"), str)
  155. )
  156. return ""
  157. def assert_advertised_tool(body: dict[str, object], expected: str) -> None:
  158. """Require the packaged deployment to expose the requested tool."""
  159. tools = body.get("tools")
  160. if not isinstance(tools, list):
  161. raise AssertionError(f"model request advertised no tools: {body}")
  162. names: set[str] = set()
  163. for tool in tools:
  164. if not isinstance(tool, dict):
  165. continue
  166. function = tool.get("function")
  167. if isinstance(function, dict) and isinstance(function.get("name"), str):
  168. names.add(function["name"])
  169. if expected not in names:
  170. raise AssertionError(f"model request did not advertise {expected}: {names}")
  171. class MockModel:
  172. def __enter__(self) -> "MockModel":
  173. MockModelHandler.requests.clear()
  174. self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler)
  175. self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
  176. self.thread.start()
  177. host, port = self.server.server_address
  178. self.url = f"http://{host}:{port}"
  179. return self
  180. def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None:
  181. self.server.shutdown()
  182. self.server.server_close()
  183. self.thread.join(timeout=5)
  184. def main() -> None:
  185. parser = argparse.ArgumentParser(description=__doc__)
  186. parser.add_argument("--scenario", choices=("all", "sdk-default", "sdk-custom", "direct"), default="all")
  187. parser.add_argument("--exe", type=Path)
  188. args = parser.parse_args()
  189. if args.scenario in {"all", "sdk-custom", "direct"} and args.exe is None:
  190. parser.error("--exe is required for custom and direct scenarios")
  191. if args.exe is not None and not args.exe.is_file():
  192. parser.error(f"runtime executable does not exist: {args.exe}")
  193. with MockModel() as model:
  194. if args.scenario in {"all", "sdk-default"}:
  195. smoke_sdk_default(model.url)
  196. if args.scenario in {"all", "sdk-custom"}:
  197. assert args.exe is not None
  198. smoke_sdk_custom(model.url, args.exe.resolve())
  199. if args.scenario in {"all", "direct"}:
  200. assert args.exe is not None
  201. smoke_direct(model.url, args.exe.resolve())
  202. if not MockModelHandler.requests:
  203. raise AssertionError("mock model endpoint received no requests")
  204. print(f"smoke-python-runtime: {args.scenario} passed")
  205. def smoke_sdk_default(base_url: str) -> None:
  206. from deepseek_harness import DeepSeekHarness
  207. with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary:
  208. root = Path(temporary).resolve()
  209. sessions = root / "sessions"
  210. with DeepSeekHarness(
  211. model="smoke-model",
  212. cwd=str(root),
  213. session_root=str(sessions),
  214. api_key="sk-keyless-smoke",
  215. base_url=base_url,
  216. request_timeout_seconds=60,
  217. ) as harness:
  218. result = harness.run("reply with the smoke text", session_id="default-smoke")
  219. assert result.status == "ok", result
  220. assert result.final_response == EXPECTED_TEXT, result.final_response
  221. assert_session_log(sessions, root, EXPECTED_TEXT)
  222. def smoke_sdk_custom(base_url: str, executable: Path) -> None:
  223. from deepseek_harness import DeepSeekHarness
  224. with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary:
  225. root = Path(temporary).resolve()
  226. sessions = root / "sessions"
  227. cordis = root / "cordis.yml"
  228. cordis.write_text(CUSTOM_CORDIS)
  229. with DeepSeekHarness(
  230. model="smoke-model",
  231. cwd=str(root),
  232. session_root=str(sessions),
  233. cordis=str(cordis),
  234. runtime_bin=str(executable),
  235. api_key="sk-keyless-smoke",
  236. base_url=base_url,
  237. request_timeout_seconds=60,
  238. ) as harness:
  239. text_result = harness.run("reply with the smoke text", session_id="custom-smoke")
  240. code_result = harness.run(CODE_PROMPT, session_id="custom-smoke")
  241. workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke")
  242. assert text_result.status == "ok", text_result
  243. assert text_result.final_response == EXPECTED_TEXT, text_result.final_response
  244. assert code_result.status == "ok", code_result
  245. assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response
  246. assert workflow_result.status == "ok", workflow_result
  247. assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response
  248. assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT)
  249. def smoke_direct(base_url: str, executable: Path) -> None:
  250. with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary:
  251. root = Path(temporary).resolve()
  252. sessions = root / "sessions"
  253. cordis = root / "cordis.yml"
  254. cordis.write_text(CUSTOM_CORDIS)
  255. environment = {
  256. **os.environ,
  257. "DSH_CORDIS_CONFIG": str(cordis),
  258. "DSH_SESSION_ROOT": str(sessions),
  259. "DSH_CWD": str(root),
  260. "DEEPSEEK_API_KEY": "sk-keyless-smoke",
  261. "DEEPSEEK_BASE_URL": base_url,
  262. }
  263. peer = RuntimePeer([str(executable)], root, environment)
  264. try:
  265. peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}})
  266. peer.read_until(lambda message: message.get("id") == "initialize")
  267. peer.send({
  268. "jsonrpc": "2.0",
  269. "id": "prompt",
  270. "method": "session/prompt",
  271. "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]},
  272. })
  273. messages = peer.read_until(lambda message: message.get("id") == "prompt")
  274. if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages):
  275. messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished"))
  276. event_text = json.dumps(messages)
  277. if EXPECTED_TEXT not in event_text:
  278. raise AssertionError(f"direct runtime emitted no final response: {messages}")
  279. peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"})
  280. peer.read_until(lambda message: message.get("id") == "shutdown")
  281. finally:
  282. peer.close()
  283. assert_session_log(sessions, root, EXPECTED_TEXT)
  284. class RuntimePeer:
  285. def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None:
  286. self.process = subprocess.Popen(
  287. argv,
  288. cwd=cwd,
  289. env=environment,
  290. stdin=subprocess.PIPE,
  291. stdout=subprocess.PIPE,
  292. stderr=subprocess.PIPE,
  293. text=True,
  294. encoding="utf-8",
  295. bufsize=1,
  296. )
  297. self.stdout: queue.Queue[str | None] = queue.Queue()
  298. self.stderr: list[str] = []
  299. threading.Thread(target=self._read_stdout, daemon=True).start()
  300. threading.Thread(target=self._read_stderr, daemon=True).start()
  301. def send(self, message: dict[str, object]) -> None:
  302. if self.process.stdin is None:
  303. raise RuntimeError("runtime stdin is unavailable")
  304. self.process.stdin.write(json.dumps(message) + "\n")
  305. self.process.stdin.flush()
  306. def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]:
  307. deadline = time.monotonic() + 60
  308. messages: list[dict[str, object]] = []
  309. while time.monotonic() < deadline:
  310. try:
  311. line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic()))
  312. except queue.Empty:
  313. continue
  314. if line is None:
  315. raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}")
  316. try:
  317. message = json.loads(line)
  318. except json.JSONDecodeError:
  319. continue
  320. messages.append(message)
  321. if predicate(message):
  322. return messages
  323. raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}")
  324. def close(self) -> None:
  325. if self.process.stdin is not None and not self.process.stdin.closed:
  326. self.process.stdin.close()
  327. try:
  328. self.process.wait(timeout=10)
  329. except subprocess.TimeoutExpired:
  330. self.process.kill()
  331. self.process.wait()
  332. if self.process.returncode not in {0, -15}:
  333. raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}")
  334. def _read_stdout(self) -> None:
  335. assert self.process.stdout is not None
  336. for line in self.process.stdout:
  337. self.stdout.put(line)
  338. self.stdout.put(None)
  339. def _read_stderr(self) -> None:
  340. assert self.process.stderr is not None
  341. self.stderr.extend(self.process.stderr)
  342. def assert_session_log(sessions: Path, cwd: Path, *expected_texts: str) -> None:
  343. logs = list(sessions.rglob("*.jsonl"))
  344. if len(logs) != 1:
  345. raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}")
  346. lines = logs[0].read_text().splitlines()
  347. header = json.loads(lines[0])
  348. if header.get("cwd") != str(cwd):
  349. raise AssertionError(f"session header cwd is not absolute/canonical: {header}")
  350. rendered = "\n".join(lines)
  351. for expected in expected_texts:
  352. if expected not in rendered:
  353. raise AssertionError(f"session log has no {expected!r} response: {logs[0]}")
  354. if __name__ == "__main__":
  355. main()