diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8e94476476..5e055a1ee5 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -8,14 +8,16 @@ name: Build single-exe # the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into # dist-exe/. # -# Each build leg uploads two artifacts: +# Each build leg uploads two artifacts (both tar.gz — tar keeps file modes, +# so the exe's executable bit survives; upload-artifact's zip transport +# does not preserve them): # - dsh-jsonrpc-agent-pkg- — the bare single-file exe, for # consumers that want just the binary. -# - deepseek-harness-python- — the whole python/ directory as a -# tar.gz with that exe already embedded (the build script syncs it into -# the Python runtime package): unpack and both packages pip install -# as-is, the checked-in default runtime/cordis.yml is editable in -# place, and the embedded exe also runs directly. +# - deepseek-harness-python- — the whole python/ directory with +# that exe already embedded (the build script syncs it into the Python +# runtime package): unpack and both packages pip install as-is, the +# checked-in default runtime/cordis.yml is editable in place, and the +# embedded exe also runs directly. # # Two explicit triggers, deliberately no per-commit CI: the exe is a # release-style deliverable, and the build (full pnpm build + pnpm deploy + @@ -154,10 +156,24 @@ jobs: - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + # The bare exe ships inside a tar.gz (the mode-preservation note in + # the header): uploading dist-exe/ bare would hand consumers a 0644 + # file that subprocess.Popen refuses to run. + - name: Pack bare exe + id: pack-exe + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + exe_tar="dsh-jsonrpc-agent-pkg-${TARGET}.tar.gz" + tar -czf "$exe_tar" -C dist-exe . + ls -lh "$exe_tar" + echo "tar=$exe_tar" >> "$GITHUB_OUTPUT" + - uses: actions/upload-artifact@v6 with: name: dsh-jsonrpc-agent-pkg-${{ matrix.target }} - path: dist-exe/ + path: ${{ steps.pack-exe.outputs.tar }} if-no-files-found: error # After the build step, python/ is already in its complete @@ -165,9 +181,7 @@ jobs: # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the # checked-in default cordis.yml — so packing is all that is left. # Everything goes under one top-level deepseek-harness-python/ - # directory so unpacking never scatters files. Shipping a tar (rather - # than uploading the tree bare) preserves the exe's executable bit — - # tar keeps file modes; upload-artifact's zip does not. + # directory so unpacking never scatters files. # # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index d82d6cfa12..6ab0e66ea5 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 5543b0ba00816c230dbe4e5bba11b7bf4b38096d -README.zh.md: 163b6d8e8373f0f770a5d95be688785907108b8a +README.md: 4cf48c55f40ec1c8632582546dcbfb990bbaa660 +README.zh.md: 0c82569be45b5cefa2ca025728945be1bdefc541 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 5543b0ba00..4cf48c55f4 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -13,7 +13,7 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. -Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow and place it in the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. +Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers. ## Resolution API diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 163b6d8e83..0c82569be4 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -13,7 +13,7 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 -载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物并放入 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 ## 解析 API diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index bd14ed0211..fdaa08f4ef 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -36,7 +36,8 @@ _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm _EXE_ACQUISITION_HINT = ( "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` " - "CI workflow; place the file in this package's runtime/ directory. For local development " + "CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into this package's " + "runtime/ directory. For local development " "against a repo source build, explicitly select the dev-only node carrier with " f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." ) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index c21759e4b7..3ee5e33a32 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 67dbd04520ac01318c1f21393c12b7bf4965b4cb -README.zh.md: 36ea4df4ff73e12ce3fda5bbee4108584dd5ad92 +README.md: ef9f8703f3bdb7b6d2dca28639f3b794083f7905 +README.zh.md: 31b6e87d7b75cc10cb799862fe6eb0f1f26dccd6 diff --git a/python/sdk/README.md b/python/sdk/README.md index 67dbd04520..ef9f8703f3 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -24,4 +24,4 @@ with DeepSeekHarness( `assistant/message` event in the turn. Use `TurnResult.events` for the complete event stream, including intermediate assistant messages and tool activity. -The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. If neither `cordis` nor `DSH_CORDIS_CONFIG` is set and the launch resolves to the bundled runtime, the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 36ea4df4ff..31b6e87d7b 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -18,4 +18,4 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 -同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。若 `cordis` 与 `DSH_CORDIS_CONFIG` 均未设置,且启动解析到内置运行时,则使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 +同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py index ab3b2f764e..dca522bf5d 100644 --- a/python/sdk/src/deepseek_harness/api.py +++ b/python/sdk/src/deepseek_harness/api.py @@ -1,6 +1,5 @@ from __future__ import annotations -import os import uuid from dataclasses import dataclass, field from pathlib import Path @@ -60,8 +59,6 @@ class DeepSeekHarness: env["DSH_SESSION_ROOT"] = self.config.session_root if self.config.cordis is not None: env["DSH_CORDIS_CONFIG"] = self.config.cordis - else: - self._inject_bundled_default_config(env) env["DSH_CWD"] = cwd if self.config.base_url is not None: env["DEEPSEEK_BASE_URL"] = self.config.base_url @@ -109,27 +106,6 @@ class DeepSeekHarness: self._client.close() self._initialized = False - def _inject_bundled_default_config(self, env: dict[str, str]) -> None: - """Restore the zero-config experience over the config-mandatory bundled runtime. - - The bundled runtime (single-file exe or the dev-only node closure) - always demands an explicit config. When the caller neither provided - ``cordis`` nor selected a runtime explicitly (``runtime_bin`` / - ``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists, - inject the runtime package's checked-in default cordis.yml. With an - explicit runtime or config channel the SDK stays out of the way. - """ - uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None - if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ: - return - try: - from deepseek_harness_runtime import bundled_default_config_path - except ImportError: - # Only the runtime package's absence reaches here; swallow it so - # HarnessClient.start() reports the actionable install error. - return - env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) - def start_session(self, session_id: str | None = None) -> "Session": self.start() return Session(self, session_id or f"session-{uuid.uuid4().hex}") diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 541c1b47c8..5a6c28d3a2 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -67,6 +67,7 @@ class HarnessClient: env = os.environ.copy() if self.config.env: env.update(self.config.env) + self._inject_bundled_default_config(env) self._proc = subprocess.Popen( args, stdin=subprocess.PIPE, @@ -411,6 +412,32 @@ class HarnessClient: ) from exc return resolve_bundled_launch_args() + def _inject_bundled_default_config(self, env: dict[str, str]) -> None: + """Restore the zero-config experience over the config-mandatory bundled runtime. + + The bundled runtime (single-file exe or the dev-only node closure) + always demands an explicit config. When the launch resolves to the + bundled runtime (no ``runtime_bin`` / ``bridge_bin`` / + ``launch_args_override``) and the merged subprocess environment has no + non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty + value as absent, so this does too — inject the runtime package's + checked-in default cordis.yml. With an explicit runtime or config + channel the client stays out of the way. + """ + uses_bundled_runtime = ( + self.config.launch_args_override is None + and self.config.runtime_bin is None + and self.config.bridge_bin is None + ) + if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): + return + # Cannot fail: _default_launch_args() already imported the runtime + # package on this (bundled) path, raising the actionable install + # error when it is absent. + from deepseek_harness_runtime import bundled_default_config_path + + env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + def _unsubscribe_notifications(self, subscription_id: str) -> None: with self._lock: self._notification_subscribers.pop(subscription_id, None) diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py index 51dfb668d8..a55c6c2c6a 100644 --- a/python/sdk/tests/test_bundled_runtime.py +++ b/python/sdk/tests/test_bundled_runtime.py @@ -17,7 +17,7 @@ import pytest from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig from deepseek_harness.errors import TransportClosedError -from deepseek_harness_runtime import bundled_default_config_path, resolve_bundled_launch_args +from deepseek_harness_runtime import resolve_bundled_launch_args _MODES = ("exe", "node") @@ -99,12 +99,16 @@ def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: @pytest.mark.parametrize("mode", _MODES) +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) def test_zero_config_run_injects_bundled_default_cordis_config( - tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch + tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch ) -> None: _launch_args(mode) # skip early when this carrier is unavailable monkeypatch.setenv("DSH_RUNTIME_MODE", mode) - monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) harness = DeepSeekHarness( model="deepseek-v4-pro", @@ -114,7 +118,8 @@ def test_zero_config_run_injects_bundled_default_cordis_config( base_url="http://127.0.0.1:9", request_timeout_seconds=120, ) - assert harness.client.config.env is not None - assert harness.client.config.env["DSH_CORDIS_CONFIG"] == str(bundled_default_config_path()) with harness: - pass # __enter__ boots the runtime on the injected default config; __exit__ shuts it down + # __enter__ boots the runtime, which exits with a usage error unless + # HarnessClient.start() injected the bundled default config over the + # unset/empty DSH_CORDIS_CONFIG; __exit__ shuts it down. + pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index 3b12a28865..08fa13a331 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -548,13 +548,23 @@ with open(os.environ["SEEN"], "w") as seen: json.loads(line) -def test_client_uses_bundled_runtime_package_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def _install_fake_bundled_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Fake the deepseek-harness-runtime-bin package on sys.path. + + A stub exe that dumps DSH_CORDIS_CONFIG to $ENV_DUMP before serving + initialize/shutdown, plus a module exposing the resolution surface the + client consumes. Returns the fake bundled default config path. + """ runtime = tmp_path / "dsh-jsonrpc-agent" runtime.write_text( """#!/usr/bin/env python3 import json +import os import sys +json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w")) for line in sys.stdin: msg = json.loads(line) if msg.get("method") == "initialize": @@ -566,22 +576,56 @@ for line in sys.stdin: ) runtime.chmod(0o755) + default_config = tmp_path / "default-cordis.yml" module_dir = tmp_path / "deepseek_harness_runtime" module_dir.mkdir() (module_dir / "__init__.py").write_text( f""" def resolve_bundled_launch_args(mode=None): return ({str(runtime)!r},) + + +def bundled_default_config_path(): + return {str(default_config)!r} """.strip() ) monkeypatch.syspath_prepend(str(tmp_path)) monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + return default_config - with HarnessClient() as client: + +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) +def test_client_default_launch_uses_bundled_runtime_and_injects_default_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None +) -> None: + env_dump = tmp_path / "env.json" + default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) + + with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client: init = client.initialize(cwd="/workspace", model="deepseek-v4-pro") assert init.serverInfo.name == "bundled-runtime" + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config) + + +def test_client_respects_explicit_config_over_bundled_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_dump = tmp_path / "env.json" + _install_fake_bundled_runtime(tmp_path, monkeypatch) + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + + with HarnessClient( + HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"}) + ) as client: + client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml" def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: