ci: validate and publish Python runtime wheels

This commit is contained in:
Yichen Jiang
2026-07-13 16:34:16 +08:00
parent 283ac7f097
commit cdd11ac587
16 changed files with 556 additions and 33 deletions
@@ -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
+18 -1
View File
@@ -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
+120
View File
@@ -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; }
@@ -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
@@ -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-<platform>-<arch>` 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-<platform>-<arch>` 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.
@@ -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-<platform>-<arch>``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-<platform>-<arch>``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 包恰好包含一个 exetag 为 `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 内行为未定义。
+2 -2
View File
@@ -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
+11 -5
View File
@@ -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.
+11 -5
View File
@@ -49,20 +49,26 @@ print(DeepSeekHarness().run("say hi").final_response) # auto-resolution picks
## 分发 Python 包
每个包各构建一个 wheelruntime 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 任何进程。
+2 -2
View File
@@ -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
+3 -1
View File
@@ -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>-<arch>` (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>-<arch>` (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.
+3 -1
View File
@@ -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>-<arch>`platform`linux`/`macos`arch`x64`/`arm64`)。目标机器无需安装 Node。这是唯一进入 wheel/sdist 分发物的载体。
- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg-<platform>-<arch>`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 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。
+2 -2
View File
@@ -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
+10
View File
@@ -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`.
+10
View File
@@ -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` 的高层便捷选项。
+261
View File
@@ -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()