diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 5e055a1ee5..e212c78d86 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -119,6 +119,13 @@ jobs: with: node-version: 24 + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + + - name: Install Python build tooling + run: python -m pip install uv==0.11.23 + - name: Enable corepack (pnpm) run: corepack enable @@ -156,6 +163,78 @@ jobs: - name: Build single-exe run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + - name: Resolve platform artifact names + id: runtime + env: + TARGET: ${{ matrix.target }} + run: | + set -euo pipefail + platform="${TARGET#node24-}" + exe="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$platform" + [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } + echo "platform=$platform" >> "$GITHUB_OUTPUT" + echo "exe=$exe" >> "$GITHUB_OUTPUT" + + - name: Full-turn SDK, custom cordis, and direct-binary smoke + run: >- + uv run --python 3.10 --group test --project python/sdk + python scripts/smoke-python-runtime.py + --scenario all + --exe "${{ steps.runtime.outputs.exe }}" + + - name: Build release-shaped SDK and runtime wheels + run: | + set -euo pipefail + python scripts/build-python-release.py \ + --package sdk \ + --tag python-v0.0.0 \ + --output-dir dist-python + python scripts/build-python-release.py \ + --package runtime \ + --tag python-v0.0.0 \ + --platform "${{ steps.runtime.outputs.platform }}" \ + --runtime-exe "${{ steps.runtime.outputs.exe }}" \ + --output-dir dist-python + + - name: Install only the SDK into a clean venv and run zero-config + run: | + set -euo pipefail + python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ + --find-links dist-python \ + deepseek-harness==0.0.0 + "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ + --scenario sdk-default + + - name: Check Linux GLIBC requirements + if: runner.os == 'Linux' + run: | + set -euo pipefail + readelf --version-info "${{ steps.runtime.outputs.exe }}" | tee glibc-versions.txt + maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" + [ -n "$maximum" ] || { echo "::error::No GLIBC requirements found"; exit 1; } + dpkg --compare-versions "$maximum" le 2.28 || { + echo "::error::Executable requires GLIBC_$maximum but wheel claims manylinux_2_28" + exit 1 + } + + - name: Run wheel in a manylinux 2.28 container + if: runner.os == 'Linux' + env: + RUNNER_ARCH: ${{ runner.arch }} + run: | + set -euo pipefail + case "$RUNNER_ARCH" in + X64) image=quay.io/pypa/manylinux_2_28_x86_64 ;; + ARM64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; + *) echo "::error::Unsupported Linux runner architecture $RUNNER_ARCH"; exit 1 ;; + esac + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' + /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness==0.0.0 + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + ' + # 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. @@ -223,3 +302,9 @@ jobs: name: deepseek-harness-python-${{ matrix.target }} path: ${{ steps.pack.outputs.bundle }} if-no-files-found: error + + - uses: actions/upload-artifact@v6 + with: + name: deepseek-harness-wheels-${{ matrix.target }} + path: dist-python/*.whl + if-no-files-found: error diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4840b88192..e45d6698e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,6 +143,23 @@ jobs: - name: Run compatibility gates run: pnpm run check:node-compat + python-sdk: + runs-on: ubuntu-latest + name: python 3.10 / keyless SDK + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-python@v6 + with: + python-version: '3.10' + cache: pip + + - name: Install uv + run: python -m pip install uv==0.11.23 + + - name: Run complete keyless Python suite + run: uv run --python 3.10 --group test --project python/sdk pytest + # Single stable required check for branch protection: require "all checks # passed" instead of enumerating matrix legs whose names change as lanes and # node versions evolve. Every other job in THIS workflow must be listed in @@ -154,7 +171,7 @@ jobs: all-checks-passed: name: all checks passed runs-on: ubuntu-latest - needs: [node-24, node-compat] + needs: [node-24, node-compat, python-sdk] if: always() steps: - name: Fail if any needed job did not succeed diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..784de70e9c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,120 @@ +workflow: + rules: + - if: '$CI_COMMIT_TAG =~ /^python-v\d+\.\d+\.\d+$/' + - when: never + +stages: + - build + - publish + +variables: + GIT_DEPTH: "0" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + +.python-tools: + before_script: + - python3 -m venv .ci-python + - . .ci-python/bin/activate + - python -m pip install uv==0.11.23 + +sdk-wheel: + extends: .python-tools + stage: build + tags: [linux-x64] + script: + - python scripts/build-python-release.py --package sdk --tag "$CI_COMMIT_TAG" --output-dir release/sdk + artifacts: + paths: [release/sdk/*.whl] + expire_in: 1 week + +.runtime-wheel: + extends: .python-tools + stage: build + script: + - corepack enable + - pnpm install --frozen-lockfile + - pnpm run verify-runtime-closure + - pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="$PKG_TARGET" + - EXE="$PWD/dist-exe/dsh-jsonrpc-agent-pkg-$PLATFORM" + - test -x "$EXE" + - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" + - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" + - python -m venv .wheel-smoke + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="${CI_COMMIT_TAG#python-v}" + - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default + - | + if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then + readelf --version-info "$EXE" > glibc-versions.txt + maximum="$(sed -n 's/.*Name: GLIBC_\([0-9.]*\).*/\1/p' glibc-versions.txt | sort -V | tail -1)" + test -n "$maximum" + dpkg --compare-versions "$maximum" le 2.28 + case "$PLATFORM" in + linux-x64) image=quay.io/pypa/manylinux_2_28_x86_64 ;; + linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; + *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; + esac + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==${CI_COMMIT_TAG#python-v} && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + fi + artifacts: + paths: [release/$PLATFORM/*.whl] + expire_in: 1 week + +runtime-linux-x64: + extends: .runtime-wheel + tags: [linux-x64] + variables: + PKG_TARGET: node24-linux-x64 + PLATFORM: linux-x64 + needs: + - job: sdk-wheel + artifacts: true + +runtime-linux-arm64: + extends: .runtime-wheel + tags: [linux-arm64] + variables: + PKG_TARGET: node24-linux-arm64 + PLATFORM: linux-arm64 + needs: + - job: sdk-wheel + artifacts: true + +runtime-macos-arm64: + extends: .runtime-wheel + tags: [macos-arm64] + variables: + PKG_TARGET: node24-macos-arm64 + PLATFORM: macos-arm64 + needs: + - job: sdk-wheel + artifacts: true + +publish-python: + stage: publish + tags: [linux-x64] + resource_group: python-release + needs: + - job: sdk-wheel + artifacts: true + - job: runtime-linux-x64 + artifacts: true + - job: runtime-linux-arm64 + artifacts: true + - job: runtime-macos-arm64 + artifacts: true + before_script: + - python3 -m venv .ci-python + - . .ci-python/bin/activate + - python -m pip install twine==6.2.0 + script: + - version="${CI_COMMIT_TAG#python-v}" + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 + - test -f "release/sdk/deepseek_harness-${version}-py3-none-any.whl" + - test -f "release/linux-x64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_x86_64.whl" + - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${version}-py3-none-manylinux_2_28_aarch64.whl" + - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${version}-py3-none-macosx_11_0_arm64.whl" + - python -m twine check release/*/*.whl + - export TWINE_USERNAME=gitlab-ci-token + - export TWINE_PASSWORD="$CI_JOB_TOKEN" + - export TWINE_REPOSITORY_URL="$CI_API_V4_URL/projects/$CI_PROJECT_ID/packages/pypi" + - python -m twine upload --non-interactive release/*/*.whl || { echo 'Publish failed. GitLab does not overwrite an existing version; create a new python-vX.Y.Z tag.'; exit 1; } diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 2d879af81f..307928d126 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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 -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 4fab5f89542753f2dd5977d4262e1310097f4545 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 56a93f2392bff655d32ec4947c357b33d9e6da5e +2026-07-10-single-file-executable-sdk-runtime-distribution.md: 0e463a23227bfb8f8eef5ee1a21bcddd9151c8e4 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2ce32f9bba20e85fa67559389be9d2d5c7e60cee diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 4fab5f8954..0e463a2322 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -36,17 +36,19 @@ Config discovery has two channels and fails loudly when both are missing: the `D Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. Two closure-completeness rules learned from measurement: in-repo pure libraries that closure members reference as peerDependency (`dsh-brand`, `dsh-timeout`, `dsh-invariants`, `@cordisjs/plugin-timer`) must be listed explicitly in the manifest's `dependencies`; deploy packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; CI static, pre-push, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → one `pkg --sea` per target → the artifacts `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` (the CI artifact) and are copied back into the runtime directory. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached and artifacts uploaded per platform; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel/sdist distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds three kinds of content: the checked-in default `runtime/cordis.yml`, the build-injected platform exe, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. + +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) accepts only stable `python-vX.Y.Z` tags and stages both packages at `X.Y.Z`, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. The SDK is a `py3-none-any` wheel; the wheel-only runtime package contains exactly one exe and uses one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`. Its Hatch hook rejects sdists, universal tags, mixed executable payloads, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. @@ -60,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the 28 pytest cases in `python/sdk/tests` cover the client protocol (18 cases, keyless, against a fake runtime peer), dual-carrier launch (6 cases, each skipping independently when the artifact is missing), and the carrier resolution rules (4 cases). End-to-end tier: the acceptance material (five keyless items: env-channel launch, fail-loud on missing config, fail-loud on a bad plugin name, config channel, full turn against a mock endpoint + JSONL on disk; two with-key items: a real turn, and a world check where the bash tool writes a marker file + clean exit) drives the exe from a real Python SDK client. Current debts: running the above suites against the current artifacts, and a built-exe e2e. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform artifact completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked; its platform wheel is then installed in a clean venv and run without `runtime_bin`. The JSON-RPC protocol is not part of the ACP snapshot system, so there is no snapshot tier (an explicitly named gap, not an oversight). Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. @@ -80,4 +82,4 @@ Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disp **Bought**: zero-dependency single-file distribution on target platforms; plugin semantics strictly identical to running from source (the same real package tree, no transpilation, no registry); the serving surface, the plugin set, and the configuration all converge on two sources of truth — `cordis.yml` plus one dependency manifest; the exe and node carriers share one tree and one semantics, so development verification never waits for packaging; official Node binaries remove the patched-binary supply-chain concern. -**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe; acceptance and coverage carry catch-up debts (see Testing). +**Paid**: artifacts on the order of 174MB with source entering the blob as-is (no bytecode obfuscation; a closed-source distribution requirement needs a separate evaluation); pkg's VFS/module-hook layer remains community-maintained (the build script pins `@yao-pkg/pkg@6.21.0`; upgrading is an explicit change); `--sea` is one invocation per target (matching CI's one leg per platform; local multi-platform builds are serial); worker-style plugins have undefined behavior inside the exe. diff --git a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 56a93f2392..2ce32f9bba 100644 --- a/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -36,17 +36,19 @@ exe 用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的 exe 的 VFS 内是**构建产物形态的真实包树**(各包 `lib/` + 真实 `node_modules`),Loader 解析插件名走标准动态 `import()`:裸包名从 VFS 内 Loader 位置沿 `node_modules` 向上解析,天然落在 VFS 内。封闭集不需要白名单代码——集合就是 VFS 里装了什么,引用集合外的名字 import 失败。 -deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。两条实测得来的闭包完整性规则:闭包成员以 peerDependency 引用的仓库内纯库(`dsh-brand`、`dsh-timeout`、`dsh-invariants`、`@cordisjs/plugin-timer`)必须显式列进清单 `dependencies`;deploy 按各包 `files` 打包,tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 +deploy root 是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm workspace 成员、零代码纯依赖清单)——「exe 装什么插件」与「Python runtime 分发什么」的合一事实源。往 exe 加插件 = 清单加一行依赖再重打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部 workspace 包,要求每个非 optional workspace peer 显式列在 runtime root,并报告「引用包 → 缺失 peer」的完整链路;CI static、pre-push 与 single-exe 构建都会在打包前运行该门禁。deploy 还会按各包 `files` 打包,因此 tsdown 拆出的共享 chunk 必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):`pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):runtime 闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直落** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`→ 注入 pkg 配置(`bin` 指闭包内 `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,`assets` 全量 glob——动态 import 对 pkg 静态分析不可见,必须显式全量打入)→ 每 target 一次 `pkg --sea` → 产物 `dsh-jsonrpc-agent-pkg--` 落 `dist-exe/`(CI artifact)并拷回 runtime 目录。deploy 四 flag 均有实测依据:`--legacy` 是未开 inject-workspace-packages 时的必选路径;hoisted 产出零符号链接文件树(pkg VFS 最稳、物理保证 cordis 单实例);关 peer 自动安装避免未发布包名触发 registry 解析;link-workspace-packages 让闭包指向 workspace/vendor 源。 -CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。Windows 是非目标。 +CI:[`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),仅显式触发——`workflow_dispatch` 手动派发,或给 PR 打 `build-exe` 标签;linux-x64 / linux-arm64(`ubuntu-24.04-arm`)/ macos-arm64 三平台原生构建,`~/.pkg-cache` 缓存,artifact 按平台上传;macOS ad-hoc 签名由 pkg 处理。每个平台都以 mock SSE 模型分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再以 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应,最后把 release 形态的 wheel 安装到干净 venv 中并在不传 `runtime_bin` 的情况下运行;Linux 还检查 GLIBC 依赖并在 manylinux 2.28 容器中运行。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受 `python-vX.Y.Z` tag 流水线,构建一个 SDK wheel 与 3 个原生 runtime wheel,再由单个串行 job 校验并发布这 4 个文件到项目 PyPI 注册表。Windows 是非目标。 ### Python SDK 分发:双载体,exe 为生产、node 为开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel/sdist 分发物。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk`(客户端)+ `python/sdk-runtime`(运行时载体包)。runtime 包数据目录三类内容:检入的默认 `runtime/cordis.yml`、构建注入的平台 exe、构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 自动解析**只找 exe**;node 载体仅显式 `DSH_RUNTIME_MODE=node` 启用(跑 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`,需系统 node ≥22.19),定位是本仓库成员的开发验证通道,不进 wheel 分发物。 + +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 只接受稳定的 `python-vX.Y.Z` tag,并以 `X.Y.Z` 暂存两个包,同时让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。SDK 是 `py3-none-any` wheel;只提供 wheel 的 runtime 包恰好包含一个 exe,tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用 tag、混合可执行载荷以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢复:调用方没给 `cordis`、没显式指定 runtime、环境无 `DSH_CORDIS_CONFIG` 时,客户端把检入的默认 `cordis.yml`(agent-core + 预载 llm-deepseek + JSONL 持久化 + bash-local + `dsh-jsonrpc` serving 条目,`!!js` 环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 @@ -60,7 +62,7 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:`python/sdk/tests` 的 28 例 pytest 覆盖客户端协议(18 例,keyless、假运行时对端)、双载体启动(6 例,产物缺失时独立 skip)与载体解析规则(4 例)。端到端层:验收物料(keyless 五项:env 通道启动、缺配置 fail-loud、坏插件名 fail-loud、配置通道、mock 端点完整回合 + JSONL 落盘;带 key 两项:真实回合、bash 工具写 marker 文件的世界校验 + 干净退出)由真实 Python SDK 客户端驱动 exe。当前欠账:上述套件对当前产物的运行与 built-exe e2e。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 import、cordis 单实例、fail-loud 配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的 keyless pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 cwd 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台产物都通过默认 SDK 路径、自定义配置和直接二进制协议对着 mock 端点完成一个轮次,并校验最终文本与 JSONL;随后把平台 wheel 安装进干净 venv,在不传 `runtime_bin` 的情况下运行。JSON-RPC 协议不在 ACP snapshot 体系内,无 snapshot 层(点名后的明确空缺,非遗漏)。 手工驱动注意:bin 视 stdin EOF 为「客户端已走」并立即 dispose,短命管道会中止在飞回合——管道驱动必须保持 stdin 打开到回合结束。 @@ -80,4 +82,4 @@ exe「必须显式配置」的硬语义不变;零配置体验由 wrapper 恢 **买到的**:目标平台零依赖单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译无注册表);serving 面、插件集、配置三者全部收敛到 `cordis.yml` + 一份依赖清单两个事实源;exe 与 node 双载体同树同语义,开发验证不必等打包;官方 Node 二进制消除了补丁二进制供应链顾虑。 -**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义;验收与覆盖率存在补跑欠账(见「测试」)。 +**付出的**:产物 174MB 级且源码原样进 blob(无字节码混淆,闭源分发诉求需另行评估);pkg 的 VFS/模块钩子层仍是社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级走显式改动);`--sea` 单 target 单次调用(与 CI 每平台一腿匹配,本地多平台构建串行);worker 类插件在 exe 内行为未定义。 diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index b337d2c288..d898ac5b36 100644 --- a/python/README.i18n.yaml +++ b/python/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: 294cbd1eb0690de17fa70f3049bb4677e1fd135a -README.zh.md: f89d1612f6ac454b3a57515b5a1a0f3c59c579d1 +README.md: a55b3b90bcb2329f58f063edc7edc733bad030d1 +README.zh.md: f99cf2302d9dbad36fff1616ae33b40b8bb02f4e diff --git a/python/README.md b/python/README.md index 294cbd1eb0..a55b3b90bc 100644 --- a/python/README.md +++ b/python/README.md @@ -49,20 +49,26 @@ Two flavors, both for repo members: ## Distributing the Python packages -Build one wheel per package; the runtime wheel embeds whatever executables are present under `runtime/` at build time (and always the default `cordis.yml`), and excludes `runtime/node/`: +Python releases use stable tags of the form `python-vX.Y.Z`. The common staging script derives both distribution versions from the tag, pins `deepseek-harness-runtime-bin==X.Y.Z` in the SDK metadata, and rejects any other tag form. Build the pure SDK wheel once and one runtime wheel on each native platform: ```sh -(cd python/sdk-runtime && uv build) # or: hatch build -(cd python/sdk && uv build) -pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python +python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness==0.1.0 ``` -Once the binaries are in place (built or downloaded), installing the two wheels (or `pip install ./python/sdk-runtime ./python/sdk` straight from the directories) gives a working zero-config `DeepSeekHarness()`. Two pre-release caveats: the runtime wheel is currently tagged `py3-none-any` while embedding platform-specific binaries — build it with only the matching platform's exe in place (one wheel per platform), or drop all three exes in for a fat universal wheel (~500 MB); and versioning/publishing policy (`0.0.0-dev`, no registry) is deliberately unset until the first tagged release. +The runtime distribution is wheel-only and rejects sdist builds, missing executables, and mixed-platform payloads. Its three wheel tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the SDK remains `py3-none-any`. A tag pipeline builds these four non-conflicting files and publishes them together, so a normal `pip install deepseek-harness==X.Y.Z` selects the matching runtime wheel and `import deepseek_harness` needs no `runtime_bin`. ## Zero-config semantics The runtime binary itself always requires an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as the first argv argument), has no built-in fallback, and boots only what the config lists. Zero-config is SDK wrapper behavior: when the caller uses no explicit channel, the client injects the runtime package's checked-in default configuration ([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml)) via `DSH_CORDIS_CONFIG`; any explicit channel wins and disables the injection. The full injection conditions live in the [sdk README](sdk/README.md); the default config's contents and the hard semantic in the [sdk-runtime README](sdk-runtime/README.md). +The executable is also a supported direct interface; keep stdin open for the NDJSON JSON-RPC exchange and supply a config explicitly: + +```sh +DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 +``` + ## Test layout `test_client.py` is fully keyless (a Python fake runtime is the peer). `test_bundled_runtime.py` boots each bundled carrier and skips per carrier when its artifact is missing. `test_runtime_resolution.py` covers the carrier-resolution rules without spawning anything. diff --git a/python/README.zh.md b/python/README.zh.md index f89d1612f6..f99cf2302d 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -49,20 +49,26 @@ print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks ## 分发 Python 包 -每个包各构建一个 wheel;runtime wheel 会内嵌构建时 `runtime/` 下存在的全部可执行文件(以及始终包含的默认 `cordis.yml`),并排除 `runtime/node/`: +Python 发布只使用 `python-vX.Y.Z` 形式的稳定 tag。统一暂存脚本从 tag 推导两个分发物的版本,在 SDK 元数据中钉死 `deepseek-harness-runtime-bin==X.Y.Z`,并拒绝其他 tag 形式。纯 SDK wheel 只构建一次,runtime wheel 则在每个原生平台各构建一个: ```sh -(cd python/sdk-runtime && uv build) # or: hatch build -(cd python/sdk && uv build) -pip install dist/deepseek_harness_runtime_bin-*.whl dist/deepseek_harness-*.whl +python scripts/build-python-release.py --package sdk --tag python-v0.1.0 --output-dir dist-python +python scripts/build-python-release.py --package runtime --tag python-v0.1.0 --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python +pip install --find-links dist-python deepseek-harness==0.1.0 ``` -二进制就位(构建或下载)后,安装这两个 wheel(或直接 `pip install ./python/sdk-runtime ./python/sdk` 从目录安装),就得到零配置可用的 `DeepSeekHarness()`。两个 pre-release 注意事项:runtime wheel 目前的标签是 `py3-none-any`,内容却是平台相关的二进制——构建时只放匹配平台的 exe(每平台一个 wheel),或把三个 exe 全部放入做成通用胖 wheel(约 500 MB);版本与发布策略(`0.0.0-dev`、无 registry)刻意留待首个 tagged release 再定。 +runtime 分发物只提供 wheel,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel tag 分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。tag 流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的 runtime wheel,`import deepseek_harness` 不需要 `runtime_bin`。 ## 零配置语义 运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的东西。零配置是 SDK 包装层的行为:调用方没有走任何显式通道时,客户端把 runtime 包检入的默认配置([runtime/cordis.yml](sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml))注入 `DSH_CORDIS_CONFIG`;任一显式通道存在即胜出并禁用注入。注入条件的完整定义见 [sdk README](sdk/README.md),默认配置的内容与硬语义见 [sdk-runtime README](sdk-runtime/README.md)。 +可执行文件也支持直接调用;在 NDJSON JSON-RPC 交互期间保持 stdin 打开,并显式提供配置: + +```sh +DSH_CORDIS_CONFIG=/absolute/path/cordis.yml ./dsh-jsonrpc-agent-pkg-macos-arm64 +``` + ## 测试布局 `test_client.py` 完全无需 key(对端是 Python 假运行时)。`test_bundled_runtime.py` 逐个启动内置载体,某载体产物缺失时对应用例跳过。`test_runtime_resolution.py` 覆盖载体解析规则,不 spawn 任何进程。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 6ab0e66ea5..e6492375f6 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: 4cf48c55f40ec1c8632582546dcbfb990bbaa660 -README.zh.md: 0c82569be45b5cefa2ca025728945be1bdefc541 +README.md: ee7c8d5254bd1b1c1e9ba06c8c9ba0c6e8dfcacc +README.zh.md: d3c44bf34c686e1aee7157b8b5d4d5b4d14bb053 diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 4cf48c55f4..ee7c8d5254 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,13 +8,15 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: -- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel/sdist distributions. +- **exe (production)** — single-file executables `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. 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 (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. +Each wheel contains exactly one executable. The fixed tags are `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, and `py3-none-macosx_11_0_arm64`; the build hook rejects `py3-none-any`, absent or multiple executables, and unsupported platform tags. Stable `python-vX.Y.Z` releases use the same `X.Y.Z` for this package and the SDK. + ## Resolution API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` — the argv tuple that launches the bundled runtime: `(exe_path,)` in exe mode, `(node_path, bin_js_path)` in node mode. Mode selection: explicit argument > `DSH_RUNTIME_MODE` env var (`exe` | `node`) > automatic. Automatic resolution finds the production exe ONLY — the dev-only node carrier must be opted into explicitly so a production deployment can never silently ride on a source build. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0c82569be4..d3c44bf34c 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,13 +8,15 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 gitignore: -- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel/sdist 分发物的载体。 +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel 分发物的载体;本包不发布 sdist。 - **node(仅限开发)**——`runtime/node/` 下的完整 deploy 闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [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 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。 +每个 wheel 只包含一个可执行文件。固定 tag 为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台 tag。稳定的 `python-vX.Y.Z` 发布为本包和 SDK 使用同一个 `X.Y.Z`。 + ## 解析 API - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]`——启动内置运行时的 argv 元组:exe 模式下为 `(exe_path,)`,node 模式下为 `(node_path, bin_js_path)`。模式选择:显式参数 > `DSH_RUNTIME_MODE` 环境变量(`exe` | `node`)> 自动。自动解析只找生产 exe——仅限开发的 node 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 3ee5e33a32..b312edc1b0 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: ef9f8703f3bdb7b6d2dca28639f3b794083f7905 -README.zh.md: 31b6e87d7b75cc10cb799862fe6eb0f1f26dccd6 +README.md: 1916086324e18ce79ea572f3c85115fda3b4ab91 +README.zh.md: 380ed1726f2b541cd66403edb68a2629071e18f0 diff --git a/python/sdk/README.md b/python/sdk/README.md index ef9f8703f3..1916086324 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -8,6 +8,14 @@ runtime inherits normal DeepSeek Harness environment variables such as endpoints directly or point those variables at a local proxy during benchmark runs. +Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: + +```py +from deepseek_harness import DeepSeekHarness + +result = DeepSeekHarness().run("Say hi.") +``` + By default, the SDK launches the bundled single-file `dsh-jsonrpc-agent` executable from the `deepseek-harness-runtime-bin` package and injects that package's default configuration (the stdio JSON-RPC server, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash) via `DSH_CORDIS_CONFIG`. To run a plugin composition of your own, keep the `@deepseek-ai/dsh-jsonrpc` entry in the config and pass the Cordis config path. ```py @@ -25,3 +33,5 @@ with DeepSeekHarness( event stream, including intermediate assistant messages and tool activity. 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. + +`cwd` and `runtime_cwd` are resolved to absolute paths before subprocess launch, environment injection, and the wire handshake. The public API exposes only applied options: deployment persona and persistence belong in `cordis.yml`, while `session_root` remains the high-level convenience that sets `DSH_SESSION_ROOT`. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 31b6e87d7b..380ed1726f 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -4,6 +4,14 @@ 通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel。因此常规入口不需要传可执行文件参数: + +```py +from deepseek_harness import DeepSeekHarness + +result = DeepSeekHarness().run("Say hi.") +``` + 默认情况下,SDK 启动 `deepseek-harness-runtime-bin` 包内置的单文件 `dsh-jsonrpc-agent` 可执行程序,并通过 `DSH_CORDIS_CONFIG` 注入该包的默认配置(stdio JSON-RPC 服务器、agent core、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash)。要运行自己用插件组合需要在配置里保留 `@deepseek-ai/dsh-jsonrpc` 条目,并传入 Cordis 配置路径。 ```py @@ -19,3 +27,5 @@ with DeepSeekHarness( `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。 同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。 + +`cwd` 与 `runtime_cwd` 会在启动子进程、注入环境变量和协议握手前解析为绝对路径。公开 API 只暴露真正生效的选项:部署 persona 与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py new file mode 100644 index 0000000000..c0d95b40d5 --- /dev/null +++ b/scripts/smoke-python-runtime.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +"""Keyless full-turn smoke for the SDK wrapper and direct NDJSON runtime use.""" + +from __future__ import annotations + +import argparse +import json +import os +import queue +import subprocess +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Callable + + +EXPECTED_TEXT = "runtime smoke ok" +CUSTOM_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD +""" + + +class MockModelHandler(BaseHTTPRequestHandler): + """Return one deterministic OpenAI-compatible streaming completion.""" + + requests: list[dict[str, object]] = [] + + def do_POST(self) -> None: + content_length = int(self.headers.get("content-length", "0")) + body = json.loads(self.rfile.read(content_length)) + self.requests.append(body) + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + chunks = [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + {"choices": [{"delta": {"content": EXPECTED_TEXT}}]}, + {"choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], "usage": {"prompt_tokens": 3, "completion_tokens": 3}}, + ] + for chunk in chunks: + self.wfile.write(f"data: {json.dumps(chunk)}\n\n".encode()) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + def log_message(self, _format: str, *_args: object) -> None: + return + + +class MockModel: + def __enter__(self) -> "MockModel": + MockModelHandler.requests.clear() + self.server = ThreadingHTTPServer(("127.0.0.1", 0), MockModelHandler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + host, port = self.server.server_address + self.url = f"http://{host}:{port}" + return self + + def __exit__(self, _exc_type: object, _exc: object, _tb: object) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join(timeout=5) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scenario", choices=("all", "sdk-default", "sdk-custom", "direct"), default="all") + parser.add_argument("--exe", type=Path) + args = parser.parse_args() + if args.scenario in {"all", "sdk-custom", "direct"} and args.exe is None: + parser.error("--exe is required for custom and direct scenarios") + if args.exe is not None and not args.exe.is_file(): + parser.error(f"runtime executable does not exist: {args.exe}") + + with MockModel() as model: + if args.scenario in {"all", "sdk-default"}: + smoke_sdk_default(model.url) + if args.scenario in {"all", "sdk-custom"}: + assert args.exe is not None + smoke_sdk_custom(model.url, args.exe.resolve()) + if args.scenario in {"all", "direct"}: + assert args.exe is not None + smoke_direct(model.url, args.exe.resolve()) + if not MockModelHandler.requests: + raise AssertionError("mock model endpoint received no requests") + print(f"smoke-python-runtime: {args.scenario} passed") + + +def smoke_sdk_default(base_url: str) -> None: + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-default-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + with DeepSeekHarness( + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run("reply with the smoke text", session_id="default-smoke") + assert result.status == "ok", result + assert result.final_response == EXPECTED_TEXT, result.final_response + assert_session_log(sessions, root) + + +def smoke_sdk_custom(base_url: str, executable: Path) -> None: + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-custom-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + with DeepSeekHarness( + model="smoke-model", + cwd=str(root), + session_root=str(sessions), + cordis=str(cordis), + runtime_bin=str(executable), + api_key="sk-keyless-smoke", + base_url=base_url, + request_timeout_seconds=60, + ) as harness: + result = harness.run("reply with the smoke text", session_id="custom-smoke") + assert result.status == "ok", result + assert result.final_response == EXPECTED_TEXT, result.final_response + assert_session_log(sessions, root) + + +def smoke_direct(base_url: str, executable: Path) -> None: + with tempfile.TemporaryDirectory(prefix="dsh-direct-") as temporary: + root = Path(temporary).resolve() + sessions = root / "sessions" + cordis = root / "cordis.yml" + cordis.write_text(CUSTOM_CORDIS) + environment = { + **os.environ, + "DSH_CORDIS_CONFIG": str(cordis), + "DSH_SESSION_ROOT": str(sessions), + "DSH_CWD": str(root), + "DEEPSEEK_API_KEY": "sk-keyless-smoke", + "DEEPSEEK_BASE_URL": base_url, + } + peer = RuntimePeer([str(executable)], root, environment) + try: + peer.send({"jsonrpc": "2.0", "id": "initialize", "method": "initialize", "params": {"cwd": str(root), "model": "smoke-model"}}) + peer.read_until(lambda message: message.get("id") == "initialize") + peer.send({ + "jsonrpc": "2.0", + "id": "prompt", + "method": "session/prompt", + "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, + }) + messages = peer.read_until(lambda message: message.get("id") == "prompt") + if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): + messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + event_text = json.dumps(messages) + if EXPECTED_TEXT not in event_text: + raise AssertionError(f"direct runtime emitted no final response: {messages}") + peer.send({"jsonrpc": "2.0", "id": "shutdown", "method": "shutdown"}) + peer.read_until(lambda message: message.get("id") == "shutdown") + finally: + peer.close() + assert_session_log(sessions, root) + + +class RuntimePeer: + def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: + self.process = subprocess.Popen( + argv, + cwd=cwd, + env=environment, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + bufsize=1, + ) + self.stdout: queue.Queue[str | None] = queue.Queue() + self.stderr: list[str] = [] + threading.Thread(target=self._read_stdout, daemon=True).start() + threading.Thread(target=self._read_stderr, daemon=True).start() + + def send(self, message: dict[str, object]) -> None: + if self.process.stdin is None: + raise RuntimeError("runtime stdin is unavailable") + self.process.stdin.write(json.dumps(message) + "\n") + self.process.stdin.flush() + + def read_until(self, predicate: Callable[[dict[str, object]], bool]) -> list[dict[str, object]]: + deadline = time.monotonic() + 60 + messages: list[dict[str, object]] = [] + while time.monotonic() < deadline: + try: + line = self.stdout.get(timeout=min(0.25, deadline - time.monotonic())) + except queue.Empty: + continue + if line is None: + raise RuntimeError(f"runtime exited before expected message; stderr: {''.join(self.stderr)}") + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + messages.append(message) + if predicate(message): + return messages + raise TimeoutError(f"runtime timed out; messages={messages}; stderr={''.join(self.stderr)}") + + def close(self) -> None: + if self.process.stdin is not None and not self.process.stdin.closed: + self.process.stdin.close() + try: + self.process.wait(timeout=10) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait() + if self.process.returncode not in {0, -15}: + raise RuntimeError(f"runtime exited {self.process.returncode}; stderr: {''.join(self.stderr)}") + + def _read_stdout(self) -> None: + assert self.process.stdout is not None + for line in self.process.stdout: + self.stdout.put(line) + self.stdout.put(None) + + def _read_stderr(self) -> None: + assert self.process.stderr is not None + self.stderr.extend(self.process.stderr) + + +def assert_session_log(sessions: Path, cwd: Path) -> None: + logs = list(sessions.rglob("*.jsonl")) + if len(logs) != 1: + raise AssertionError(f"expected one JSONL session log under {sessions}, found {logs}") + lines = logs[0].read_text().splitlines() + header = json.loads(lines[0]) + if header.get("cwd") != str(cwd): + raise AssertionError(f"session header cwd is not absolute/canonical: {header}") + if EXPECTED_TEXT not in "\n".join(lines): + raise AssertionError(f"session log has no final response: {logs[0]}") + + +if __name__ == "__main__": + main()