diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml new file mode 100644 index 0000000000..5bf4f7f239 --- /dev/null +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -0,0 +1,287 @@ +name: Build single-exe + +# Single-file executable (single-exe) builds of the DeepSeek Harness SDK +# runtime. The build pipeline and target platforms are specified in +# docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md: +# each target is built natively on a runner of its own platform (no +# cross-compilation) by scripts/build-exe-for-python-sdk.ts, which deploys +# the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into +# dist-exe/. +# +# The run retains exactly the four wheels that make up one Python release: +# one platform-independent SDK wheel plus one native runtime wheel for each +# supported platform. The matrix still exercises the bare executable and +# source tree, but they are intermediate test inputs rather than artifacts. +# +# Two explicit triggers, deliberately no per-commit CI: the exe is a +# release-style deliverable, and the build (full pnpm build + pnpm deploy + +# pkg across a 3-platform matrix, ~100MB per artifact) is far too expensive +# to run on every push. Either dispatch it from the Actions tab, or put the +# `build-exe` label on a pull request to build that PR's merge result +# (remove and re-apply the label to rerun); any other label leaves the jobs +# skipped. There is no `ref` input on purpose: actions/checkout already +# checks out the ref the run was triggered on — the dispatched branch/tag, +# or the PR merge ref. +on: + workflow_dispatch: + inputs: + targets: + description: >- + Comma-separated pkg targets to build. Any subset of: + node24-linux-x64, node24-linux-arm64, node24-macos-arm64. + Empty builds all three. + type: string + required: false + default: '' + pull_request: + types: [labeled] + +# Runs on the same ref supersede each other (per branch/tag for dispatch, +# per PR merge ref for label runs). +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +# Least privilege: the jobs only read the repo; artifact upload needs no +# extra scope. +permissions: + contents: read + +jobs: + # Turn the `targets` input into the build matrix. The `matrix` context is + # not available in a job-level `if:` (jobs..if only sees + # github/needs/vars/inputs), so target selection happens here instead of + # skipping matrix legs; an unknown target name fails the whole run loudly + # instead of being silently ignored. The label gate lives here too: `build` + # needs this job, so skipping it skips the whole run. + plan: + name: plan targets + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'build-exe' + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + matrix: ${{ steps.plan.outputs.matrix }} + version: ${{ steps.version.outputs.version }} + steps: + - uses: actions/checkout@v6 + + - name: Resolve repository version + id: version + run: | + set -euo pipefail + version="$(jq -r '.version // empty' package.json)" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "::error::package.json version must be stable X.Y.Z, got '$version'" + exit 1 + } + echo "version=$version" >> "$GITHUB_OUTPUT" + + - name: Compute matrix from targets input + id: plan + env: + # Empty on label runs and on dispatch with the input left blank — + # both mean "all three targets". + TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64' }} + run: | + set -euo pipefail + matrix='[]' + IFS=',' read -r -a targets <<< "$TARGETS" + for raw in "${targets[@]}"; do + t="$(echo "$raw" | xargs)" # trim surrounding whitespace + [ -z "$t" ] && continue + # Native builds only — each target maps to a runner of its own + # platform: linux-arm64 uses GitHub's hosted arm64 label + # ubuntu-24.04-arm (there is no ubuntu-latest-arm), macos-arm64 + # uses macos-latest (Apple Silicon since macos-14). + case "$t" in + node24-linux-x64) runner=ubuntu-latest ;; + node24-linux-arm64) runner=ubuntu-24.04-arm ;; + node24-macos-arm64) runner=macos-latest ;; + *) + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64." + exit 1 + ;; + esac + matrix="$(jq -c --arg target "$t" --arg runner "$runner" '. + [{target: $target, runner: $runner}]' <<< "$matrix")" + done + if [ "$matrix" = '[]' ]; then + echo "::error::The targets input selected nothing to build." + exit 1 + fi + echo "Matrix: $matrix" + echo "matrix=$matrix" >> "$GITHUB_OUTPUT" + + sdk-wheel: + needs: plan + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v6 + + - 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: Build release-shaped SDK wheel + run: >- + python scripts/build-python-release.py + --package sdk + --output-dir dist-python + + - uses: actions/upload-artifact@v6 + with: + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + if-no-files-found: error + + build: + needs: [plan, sdk-wheel] + name: ${{ matrix.target }} + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + include: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v6 + 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 + + - name: Resolve pnpm store path + id: pnpm-store + run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT" + + # Unlike ci.yml (x64-only), this matrix spans two Linux architectures + # that share runner.os, so runner.arch is part of the key. + - uses: actions/cache@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-${{ runner.arch }}-node-24-pnpm- + + # The first run per target has pkg-fetch download yao-pkg's patched + # Node binary into ~/.pkg-cache; cache it so later runs skip the + # download. The target string pins Node major + platform + arch; + # pnpm-lock.yaml rolls the key when @yao-pkg/pkg (and with it the + # pinned patched-binary version) is bumped, with restore-keys still + # seeding from the previous cache. + - uses: actions/cache@v4 + with: + path: ~/.pkg-cache + key: pkg-fetch-${{ matrix.target }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pkg-fetch-${{ matrix.target }}- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + # The script runs the whole pipeline itself (pnpm run build → pnpm + # deploy --prod → pkg) and writes its output to dist-exe/ by default. + - name: Build single-exe + run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }} + + - name: Resolve platform outputs + id: runtime + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ needs.plan.outputs.version }} + 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; } + case "$platform" in + linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; + linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; + macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_11_0_arm64.whl ;; + *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; + esac + echo "platform=$platform" >> "$GITHUB_OUTPUT" + echo "exe=$exe" >> "$GITHUB_OUTPUT" + echo "wheel=$wheel" >> "$GITHUB_OUTPUT" + + - name: Full-turn SDK, executable snapshot, 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 runtime wheel + run: >- + python scripts/build-python-release.py + --package runtime + --platform "${{ steps.runtime.outputs.platform }}" + --runtime-exe "${{ steps.runtime.outputs.exe }}" + --output-dir dist-python + + - uses: actions/download-artifact@v8 + with: + name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python + + - name: Install only the SDK into a clean venv and run zero-config + env: + VERSION: ${{ needs.plan.outputs.version }} + 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=="$VERSION" + "$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 }} + VERSION: ${{ needs.plan.outputs.version }} + 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 -e VERSION -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=="$VERSION" + /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default + ' + + - uses: actions/upload-artifact@v6 + with: + name: ${{ steps.runtime.outputs.wheel }} + path: dist-python/${{ steps.runtime.outputs.wheel }} + 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/.gitignore b/.gitignore index df36ca9214..90709e81c0 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,8 @@ tmp/ .DS_Store .idea mise.toml +dist-exe/ +python/sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-* +python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/ +python/**/__pycache__/ +python/**/.pytest_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000..b4af9e3ecb --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,123 @@ +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 + - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } + - 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=="$DSH_VERSION" + - .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==$DSH_VERSION && /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 + - export DSH_VERSION="$(python -c 'import json; print(json.load(open("package.json"))["version"])')" + - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } + - python -m pip install twine==6.2.0 + script: + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 + - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" + - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" + - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_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/.jscpd.json b/.jscpd.json new file mode 100644 index 0000000000..86b7343d5c --- /dev/null +++ b/.jscpd.json @@ -0,0 +1,14 @@ +{ + "minTokens": 100, + "minLines": 10, + "mode": "mild", + "format": ["typescript"], + "pattern": "**/src/**/*.ts", + "ignorePattern": [ + "(?s)/\\* jscpd:ignore-start \\*/.*?/\\* jscpd:ignore-end \\*/" + ], + "reporters": ["console"], + "exitCode": 1, + "noColors": true, + "noTips": true +} diff --git a/AGENTS.md b/AGENTS.md index 843e09e8b2..84c5827502 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP bridge, app-boot glue, stdio/ACP app bins, user-approval and user-interaction seams, ask-user tool + ui/ ACP bridge, JSON-RPC SDK server, app-boot glue, stdio/ACP/SDK app bins, user-approval and user-interaction seams, ask-user tool support/ dev/test infrastructure packages util/ zero-dependency utilities examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 93f8e311f9..6172295667 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -212,7 +212,7 @@ export interface Config { } ``` -Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:29`](../packages/code-runtime/code-runtime-worker/src/index.ts) +Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:30`](../packages/code-runtime/code-runtime-worker/src/index.ts) ## `@deepseek-ai/dsh-compact-basic` @@ -328,6 +328,42 @@ export interface Config { Source: [`packages/hooks/hooks-codex/src/index.ts:43`](../packages/hooks/hooks-codex/src/index.ts) +## `@deepseek-ai/dsh-jsonrpc` + +Requires: `agents` + +```ts config-catalog +/** + * Plugin config. Every field is a runtime-only test seam — none is part of the + * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` + * (production always serves the process stdio and exits via `process.exit`). + */ +export interface JsonRpcConfig { + /** + * Transport input override. Production omits this (the plugin reads + * `process.stdin`); tests inject an in-memory `Readable` to drive the server + * without a subprocess. + */ + input?: Readable + /** + * Transport output override. Production omits this (the plugin writes + * `process.stdout` — the protocol channel); tests inject an in-memory + * `Writable` to capture frames. + */ + output?: Writable + /** + * Process-exit override for the `shutdown` request path. Production omits + * this (`process.exit`); tests inject a recorder so a driven shutdown does + * not kill the test process. + */ + exit?: (code: number) => void +} +``` + +Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`) + +Source: [`packages/ui/jsonrpc/src/index.ts:55`](../packages/ui/jsonrpc/src/index.ts) + ## `@deepseek-ai/dsh-llm-deepseek` Requires: `llm` @@ -1168,6 +1204,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-app-boot` ([`packages/ui/app-boot/src/index.ts`](../packages/ui/app-boot/src/index.ts)) - `@deepseek-ai/dsh-brand` ([`packages/util/brand/src/index.ts`](../packages/util/brand/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-jsonrpc-agent` ([`packages/ui/jsonrpc-agent/src/index.ts`](../packages/ui/jsonrpc-agent/src/index.ts)) - `@deepseek-ai/dsh-scope` ([`packages/core/scope/src/index.ts`](../packages/core/scope/src/index.ts)) - `@deepseek-ai/dsh-subagent-inprocess` ([`packages/subagent/subagent-inprocess/src/index.ts`](../packages/subagent/subagent-inprocess/src/index.ts)) - `@deepseek-ai/dsh-subagent-subprocess` ([`packages/subagent/subagent-subprocess/src/index.ts`](../packages/subagent/subagent-subprocess/src/index.ts)) diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1ab6931696..852d403e2b 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -18,7 +18,7 @@ packages/// Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 10720c8abc..a7e2c8bb37 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | @@ -25,13 +25,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:109`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:51`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio-agent`](../packages/ui/stdio-agent) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index 7a2407ed36..2c81577523 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/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: 6e2bbd27c3288037bafeb6cc71b801d56b956ab4 -README.zh.md: 04c99ae336cf1e96cbc185f0ccbd063ef8977944 +README.md: 81e958c8ae9552222f27b0f9674d4fa0ff2a481c +README.zh.md: 3764a9ade69f3ad99e36ccccbaed1d19d8bbb1f0 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 6e2bbd27c3..81e958c8ae 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -35,7 +35,7 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co ## Scope, exclusions, and rollout -**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. +**Scope**: the root `README.md`, everything under `docs/**`, and everything under `python/**`. Package READMEs (`packages/**`) join the scope in a later batch. **Excluded** (never paired, and the gate rejects a `.zh.md` or `.i18n.yaml` for them): diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 04c99ae336..3764a9ade6 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -35,7 +35,7 @@ ## 范围、排除与推进 -**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 +**范围**:根 `README.md`、`docs/**` 下的全部内容,以及 `python/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 **排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`): diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index f95a8e3a8f..1d52a8c221 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -22,6 +22,7 @@ | RAG | RAG | 首次出现可写:检索增强生成(RAG) | | SDK | SDK | | | SSE | SSE | 首次出现可写:SSE(Server-Sent Events) | +| VFS | VFS | 首次出现写:虚拟文件系统(VFS) | | agent | agent | 首次出现可写:agent(智能体) | | agent loop | agent loop | | | backlog | backlog | 双语翻译语境指待翻清单 | @@ -52,6 +53,7 @@ | block | 块 | | | background task | 后台任务 | | | backend | 后端 | | +| build target | 构建目标 | | | capability | 能力 | | | cancel | 取消 | | | checkpoint | 检查点 | | @@ -66,6 +68,7 @@ | coverage | 覆盖率 | | | crash recovery | 崩溃恢复 | | | dispose | dispose | 首次出现可写:dispose(释放资源);正文优先保留英文 | +| deploy root | 部署根目录 | | | durability | 持久性 | | | enforcement frontier | 强制边界 | i18n 机制词:manifest `required` 清单所划的门禁生效范围 | | event log | 事件日志 | | @@ -95,6 +98,7 @@ | module | 模块 | | | orphan | 孤立 | git 官方中文同译(如「孤立分支」);指英文源已不存在的 `.zh.md`;不要译作:孤儿 | | pairing | 配对 | | +| peer dependency | 对等依赖 | 首次出现写:对等依赖(peer dependency) | | permission | 权限 | | | persistence | 持久化 | | | pipeline | 流水线 | | @@ -110,6 +114,7 @@ | runtime | 运行时 | | | sandbox | 沙箱 | | | service | 服务 | | +| serving surface | 对外服务接口 | | | session | 会话 | | | session event | 会话事件 | | | smoke test | 冒烟测试 | | @@ -133,4 +138,6 @@ | turn | 轮次 | | | typecheck | 类型检查 | | | vocabulary | 词汇 | | +| wheel | wheel 包 | | | workflow | 工作流 | | +| wrapper | 包装层 | | diff --git a/docs/module-graph.md b/docs/module-graph.md index 2acee6ffc5..e4d0b3367d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -92,6 +92,8 @@ flowchart TD pkg_acp["acp"] pkg_acp_agent["acp-agent"] pkg_app_boot["app-boot"] + pkg_jsonrpc["jsonrpc"] + pkg_jsonrpc_agent["jsonrpc-agent"] pkg_stdio_agent["stdio-agent"] pkg_tool_ask_user["tool-ask-user"] pkg_user_approval["user-approval"] @@ -284,6 +286,11 @@ flowchart TD pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm pkg_subagent_mock --> pkg_subagent + pkg_jsonrpc --> pkg_agent + pkg_jsonrpc --> pkg_llm + pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_session + pkg_jsonrpc --> pkg_subagent pkg_workflow_workerthread --> pkg_agent pkg_workflow_workerthread --> pkg_brand pkg_workflow_workerthread --> pkg_llm @@ -323,6 +330,7 @@ flowchart TD | [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | `subagent` | — | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | — | | [`app-boot`](../packages/ui/app-boot) | `ui` | — | +| [`jsonrpc-agent`](../packages/ui/jsonrpc-agent) | `ui` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | | [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime) | @@ -377,6 +385,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 5f534ae2f2..a5334a63e1 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -139,6 +139,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [A shared timeout/deadline primitive, with hard-kill left to each capability](implemented/architecture/2026-07-06-timeout-deadline-library.md) | 2026-07-06 | | [Tool-call timeout policy as a plugin](implemented/architecture/2026-07-07-tool-call-timeout-policy.md) | 2026-07-07 | | [The agent is a registration scope](implemented/architecture/2026-07-08-agent-scope-contexts.md) | 2026-07-08 | +| [Single-file executable SDK runtime distribution (single-exe)](implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 2026-07-10 | | [Agent-scope runtime design and correctness](implemented/architecture/2026-07-12-agent-scope-runtime-design.md) | 2026-07-12 | ### Process 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 new file mode 100644 index 0000000000..db8c7eb8ee --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 4170d9f63773ef998bc5393fabc98a2b9fcba2fa +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7d80fb572ea36010df4829121fabe8cf66944f6b 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 new file mode 100644 index 0000000000..4170d9f637 --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -0,0 +1,85 @@ +# RFC: Single-file executable SDK runtime distribution (single-exe) + +Status: implemented + +English | [中文](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md) + +## Problem + +DeepSeek Harness needs a dedicated SDK distribution form for the Python library — no Node installation, runs directly on the target platform: a single-file executable (hereafter "the exe") that exposes a stdio JSON-RPC serving surface (`HarnessSdkServer`, the Python SDK's peer), where the plugins and configuration actually booted are decided entirely by a `cordis.yml` supplied from outside the exe. + +- The JSONRPC protocol for talking to the Python SDK is already validated +- A standardized way for cordis.yml to load every plugin (ESModule) is needed +- The distribution must carry the Node runtime, and support a locally linked source debugging mode + +## Decision + +### Packaging route: @yao-pkg/pkg's `--sea` mode + +The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](https://github.com/yao-pkg/pkg) (the actively maintained fork after vercel/pkg was archived). Relative to Node's native SEA, pkg adds a `/snapshot` VFS and runtime module hooks on top, hands the ESM entry to Node's default ESM loader unchanged, and depends on no ESM→CJS transpilation. +> Measured (macos-arm64, node24 target, pkg 6.21.0): bare-specifier ESM dynamic import inside the VFS (including top-level await), CJS interop, `node:sqlite`, fail-loud on package names outside the set, and on-disk ESM import outside the VFS all pass; `import.meta.url` comes through unchanged as `file:///snapshot/...`. + +`--sea` requires target ≥ node22; the exe uniformly targets node24. One pkg invocation packages exactly one target; multi-platform builds invoke it once per platform. + +Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay goldens, `$DSH_SNAPSHOT`); this document says "VFS" for the former. + +### The serving surface is a plugin: the two packages ui/jsonrpc + ui/jsonrpc-agent + +The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `ui/acp` + `ui/acp-agent` pattern — the serving surface is itself a plugin: + +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md) (`@deepseek-ai/dsh-jsonrpc`): the pure protocol plugin; on apply it mounts `HarnessSdkServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering the `shutdown` request it disposes its own fiber, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). +- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md) (`@deepseek-ai/dsh-jsonrpc-agent`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-jsonrpc` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). + +Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. + +### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root + +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. [`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): 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 executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. CI treats them as intermediate test inputs and retains their platform wheels. 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; 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. The run retains only four artifacts, each containing one release file: the platform-independent SDK wheel and the three native runtime wheels; bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, 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 distributions. + +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. 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`. + +### Naming lineage + +`@deepseek-ai/dsh-jsonrpc-agent` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. + +## Disposition of worker-style plugins + +`dsh-workflow-workerthread` and `dsh-code-runtime-worker` are supported inside the exe. Their built hosts convert the sibling `lib/worker.cjs` URL with `fileURLToPath()` and pass the resulting filesystem string to `Worker`, which is the form pkg's Worker hook resolves inside the VFS. The worker entries are CommonJS because that hook compiles VFS worker files as CommonJS. The workflow engine keeps its data-URL bootstrap for unbuilt source execution; only its built sibling entry uses the filesystem string. The custom-config executable smoke loads both backends, invokes a real `run_code` call and a zero-agent `workflow` call, and requires each worker to return `42` from inside pkg's VFS. + +## 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 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 build 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. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The comparison normalizes the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. + +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. + +## Alternatives considered + +**Bare Node native SEA.** The injected main script must be a single CJS file, and the blob carries no filesystem and no module resolution, so a dynamic import of a bare specifier has nothing to resolve against; the only option is compiling plugins statically into the main script and registering them by hand — bypassing standard module resolution and hardcoding the plugin set, contrary to "configuration decides everything". The final route is in fact "the official SEA foundation + pkg's VFS/module-hook layer"; what was rejected is the bare use, not SEA itself. + +**pkg standard mode.** Killed by the PoC, not a trade-off: it turns ESM into CJS + V8 bytecode via esbuild, the runtime vm compilation wires up no dynamic-import callback, every `import()` throws `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`, and `--options experimental-require-module` has no effect; it also depends on community-patched Node binaries (no macos-arm64 prebuilt; compiling from source on the spot takes about 10 minutes). Zero viability for this repo's architecture. + +**Pre-bundling each package ESM→CJS into the VFS.** The compromise that keeps real resolution semantics and only downgrades the module format; `--sea` passed measurement outright, so this layer of build complexity never needed introducing. + +**jsonrpc-agent carrying the full closure dependencies.** The app bin would declare 53+ dependencies it never imports — a "packaging manifest" masquerading as real dependency relationships — and would force constraints to open two exceptions for it, cordis-in-dependencies and a files wildcard. With the closure manifest landing on the python-side manifest package, constraints needs no exception at all and the bin keeps the normal package shape isomorphic to acp-agent. + +**An open plugin set (loading user plugins from disk).** This round ships a closed set; the PoC incidentally confirmed that on-disk ESM import outside the VFS works (through the `ctx.baseUrl` relative-path channel). It is listed as a future evolution, which must separately solve sharing the cordis instance inside the exe with external plugins. + +## Consequences + +**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). 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 new file mode 100644 index 0000000000..7d80fb572e --- /dev/null +++ b/docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -0,0 +1,85 @@ +# RFC: 单文件可执行的 SDK 运行时分发(single-exe) + +Status: implemented + +[English](2026-07-10-single-file-executable-sdk-runtime-distribution.md) | 中文 + +## 问题 + +DeepSeek Harness 需要为 Python 库专门提供一种无需安装 Node、可直接在目标平台运行的 SDK 分发形态:一个单文件可执行程序(下称 exe),通过 stdio 提供 JSON-RPC 对外服务接口(`HarnessSdkServer`,Python SDK 的对端),且实际启动的插件与配置完全由 exe 外部输入的 `cordis.yml` 决定。 + +- 与 Python SDK 通信的 JSON-RPC 协议已经过验证 +- 需要提供通过标准化 `cordis.yml` 加载所有插件(ES 模块)的能力 +- 分发物要自带 Node 运行时,并支持本地源码链接的调试模式 + +## 决策 + +### 打包路线:@yao-pkg/pkg 的 `--sea` 模式 + +exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后的活跃维护 fork)的 **`--sea`(enhanced SEA)模式**打包。相比 Node 原生 SEA,pkg 在其上增加 `/snapshot` 虚拟文件系统(VFS)与运行时模块钩子,将 ESM 入口原样交给 Node 默认的 ESM loader,不依赖任何 ESM→CJS 转译。 +> 实测(macos-arm64、node24 构建目标、pkg 6.21.0):VFS 内裸包名 ESM 动态 `import()`(含顶层 `await`)、CJS 互操作、`node:sqlite`、集合外包名明确报错、VFS 外磁盘 ESM `import()` 全部通过,`import.meta.url` 原样为 `file:///snapshot/...`。 + +`--sea` 要求构建目标 ≥ node22,exe 统一以 node24 为构建目标;每次 pkg 调用只打包一个构建目标,多平台各调用一次。 + +术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的“快照”(ACP 回放 golden、`$DSH_SNAPSHOT`)无关,本文用“VFS”指前者。 + +### 对外服务接口也是插件:ui/jsonrpc + ui/jsonrpc-agent 两包 + +确定性协议实现(`server.ts` / `transport.ts`)按 `ui/acp` + `ui/acp-agent` 的既有模式落为两包——对外服务接口本身也是插件: + +- [`packages/ui/jsonrpc`](../../../../packages/ui/jsonrpc/README.md)(`@deepseek-ai/dsh-jsonrpc`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkServer` 与按行传输的 JSON-RPC 层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答 `shutdown` 请求后 dispose 自身 fiber,再调用 `exit(0)`;HMR 式卸载只停止服务,不退出进程)。 +- [`packages/ui/jsonrpc-agent`](../../../../packages/ui/jsonrpc-agent/README.md)(`@deepseek-ai/dsh-jsonrpc-agent`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/ui/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-jsonrpc` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 + +配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——“实际启动的插件由外部 `cordis.yml` 决定”是硬语义。 + +### 插件解析:VFS 装载真实包树,闭包清单就是部署根目录 + +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 + +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖清单),也是“exe 安装哪些插件”与“Python 运行时分发什么”的统一事实源。向 exe 添加插件,就是在清单中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该清单覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;CI 静态检查、pre-push 与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `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 静态分析不可见,必须显式打入全部内容)→ 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。CI 将这些文件作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/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`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用模拟 SSE 模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。整次运行只保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包和 3 个原生运行时 wheel 包;裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 + +### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 + +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-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) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;只提供 wheel 包的运行时包恰好包含一个 exe,标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64`。其 Hatch 钩子拒绝 sdist、通用标签、混合可执行载荷以及不支持的平台。 + +exe“必须显式配置”的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 + +### 命名血统 + +`@deepseek-ai/dsh-jsonrpc-agent`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包清单;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 + +## 工作线程插件 + +exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个后端构建后的宿主都通过 `fileURLToPath()` 转换相邻 `lib/worker.cjs` 的 URL,再将所得文件系统字符串传给 `Worker`;pkg 的 Worker 钩子可以用这种形式解析 VFS 内文件。该钩子会把 VFS 内的工作线程文件作为 CommonJS 编译,所以工作线程入口采用 CommonJS。工作流引擎在未构建的源码执行中仍保留 `data:` URL 引导程序,只有构建后的相邻入口使用文件系统字符串。自定义配置的可执行文件冒烟测试会加载两个后端,实际调用 `run_code` 与不启动 agent 的 `workflow`,并要求两个工作线程都从 pkg 的 VFS 内返回 `42`。 + +## 测试 + +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在“决策”各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以假运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对模拟端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个由 spawn 提供方直接启动的 subagent(子 agent)和一个会通过 spawn 启动第二个子 agent 的工作流,随后卸载该插件。比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 + +手工驱动注意:`bin` 将 stdin EOF 视为“客户端已离开”并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 + +## 曾考虑的替代方案 + +**裸用 Node 原生 SEA。** 注入的主脚本必须是 CJS 单文件,blob 内没有文件系统与模块解析,因此动态 `import()` 无法解析裸包名;只能把插件静态编译进主脚本并手工注册。这会绕过标准模块解析并硬编码插件集合,与“配置决定一切”相悖。最终路线实际是“官方 SEA 基础 + pkg 的 VFS/模块钩子层”;否决的是裸用方式,而不是 SEA 本身。 + +**pkg 标准模式。** PoC 证明该模式不可行,而非权衡后放弃:它通过 esbuild 将 ESM 转为 CJS + V8 字节码,但运行时 VM 编译没有接入动态 `import()` 回调,任何 `import()` 都会抛出 `ERR_VM_DYNAMIC_IMPORT_CALLBACK_MISSING`,`--options experimental-require-module` 也无效;此外,它依赖社区补丁版 Node 二进制(macos-arm64 没有预编译版本,现场从源码编译约需 10 分钟)。该模式不适用于本仓库架构。 + +**每包 ESM→CJS 预打包进 VFS。** 保持真实解析语义、只降级模块格式的折中;`--sea` 直接通过实测,这层构建复杂度无需引入。 + +**让 jsonrpc-agent 承担完整闭包依赖。** 应用入口将声明 53 个以上自身并不 `import()` 的依赖,使“打包清单”伪装成真实依赖关系,还会迫使 `constraints` 为其增加 `cordis-in-dependencies` 与 `files` 通配符两个例外。将闭包清单放在 Python 侧的清单包后,`constraints` 不需要任何例外,`bin` 也能保持与 acp-agent 同构的正常包形状。 + +**开放插件集(从磁盘加载用户插件)。** 本期采用封闭集;PoC 同时证实,可以通过 `ctx.baseUrl` 相对路径通道从 VFS 外的磁盘 `import()` ESM。该能力列为后续演进,届时还需解决外部插件与 exe 内 Cordis 实例的共享问题。 + +## 后果 + +**买到的**:目标平台零依赖的单文件分发;插件语义与源码运行严格一致(同一棵真实包树,无转译、无注册表);对外服务接口、插件集与配置全部收敛到 `cordis.yml` 和一份依赖清单这两个事实源;exe 与 `node` 双载体使用同一棵树和相同语义,开发验证无需等待打包;官方 Node 二进制消除了补丁版二进制的供应链顾虑。 + +**付出的**:产物约 174MB,且源码原样进入 blob(没有字节码混淆;闭源分发诉求需要另行评估);pkg 的 VFS/模块钩子层仍由社区维护(构建脚本钉死 `@yao-pkg/pkg@6.21.0`,升级需要显式改动);`--sea` 每个构建目标调用一次(与 CI 每个平台一个任务相匹配,本地多平台构建串行执行)。 diff --git a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md index 9fef336d9c..11e52eb8de 100644 --- a/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md +++ b/docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md @@ -26,7 +26,7 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre **Why node:worker_threads**: one run uses one unpooled worker because a workflow run is already heavyweight relative to thread startup. The script runs in a vm context inside the worker, keeping the script-visible surface to the hook contract instead of exposing a bare worker realm, while `agent()` bridges by message-port RPC to I/O-bound child loops on the host. This keeps `start()` from blocking the host on the script's synchronous slice, makes the post-cancel deadline end in a real `worker.terminate()`, and gives cross-thread values a serialization boundary by construction. isolated-vm was rejected for its maintenance state, required `--no-node-snapshot` consumer flag on Node ≥ 20, and node-gyp fallback. -Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.js`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. +Host-side meta validation and body pre-parsing preserve the seam's synchronous errors, and private enum-keyed payload maps define the wire protocol. Pending async starts, published child records, one host cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across that wire; the [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns those race algorithms. Coverage uses an in-process `MessageChannel` for worker-side logic that main-process V8 coverage cannot see and separately proves the built `lib/worker.cjs`—a second tsdown entry sanctioned by the `"./worker"` subpath export—under plain Node in the built-bin smoke gate. **Meta as data, never evaluated**: the meta block reaches the seam as a plain JSON request field (the tool's schema-validated `meta` parameter) and the engine only shape-validates it, every violation named. This is a host-isolation invariant, not a convenience: evaluating a meta literal host-side — even one contractually "pure", in an empty timed vm context — hands script-controlled getters a host stack with no timeout the moment the result is READ, defeating the exact spin isolation the worker thread buys. diff --git a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md index 6ea62b2c4e..7a574a3388 100644 --- a/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md +++ b/docs/rfc/implemented/process/2026-06-16-pnpm-over-yarn.md @@ -15,7 +15,7 @@ Adopt **pnpm 11.7.0**, pinned via the `packageManager` field and installed throu - **Workspaces** move from the `package.json` `workspaces` array + `.yarnrc.yml` to `pnpm-workspace.yaml` (`vendor/*`, `packages/*` — the same globs; `examples/*` stay non-workspace, matching the prior setup and tsdown's explicit globs). - **Strict symlinked linker** (pnpm's default) replaces Yarn's hoisted `node-modules` linker. We deliberately add **no** `node-linker=hoisted` / `shamefully-hoist` escape hatch: pnpm's non-flat `node_modules` makes phantom dependencies (importing an undeclared transitive dep) fail loudly, which is a *feature* for a repo whose whole quality story is mechanical gates ([mechanical quality gates](2026-06-11-quality-gates.md)). The gate suite — typecheck, lint, test, build, knip — is the safety net that proves no such phantom imports exist. - **Build-script allowlist.** pnpm 10+ does not run dependency lifecycle scripts unless allowlisted. `pnpm-workspace.yaml` carries an explicit `allowBuilds` map (`esbuild`, `lefthook`, `@google/genai`, `protobufjs`) — the same supply-chain-hardening posture the repo already takes toward model/tool output, now applied to install-time code execution. `peerDependencyRules.allowedVersions.typescript: '>=5 <7'` silences benign peer-range warnings for the in-repo TypeScript. -- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, `version: 0.0.1`, `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. +- **Constraints become package-manager-independent.** `yarn.config.cjs` (which imported `@yarnpkg/types` and used `Yarn.workspaces()` / `workspace.set()`) is replaced by `scripts/check-workspace-constraints.ts`, a plain tsx script run as `pnpm run constraints`. It enforces the identical invariants — every package `private: true`; `@deepseek-ai/dsh-*` packages declare `cordis` as both a peer- and dev-dependency with matching ranges, use the root `package.json` version, and set `type: module`; vendored packages checked for privacy only — over the same `vendor` + `packages` scope. - All `yarn …` verbs across CI, lefthook hooks, `package.json` scripts, and docs become `pnpm …` / `pnpm run …`. `yarn.lock` → `pnpm-lock.yaml` (lockfile v9). `.gitignore` swaps `.yarn/` for `.pnpm-store/`. Vendored READMEs (e.g. `vendor/cordis/README.md`) keep their upstream `yarn` examples untouched per the Vendoring Policy. ## Alternatives considered diff --git a/docs/testing.md b/docs/testing.md index 62989a1096..23cca651fe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.js`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/eslint.config.mjs b/eslint.config.mjs index c62d3e9739..955e0fc83e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,4 +1,5 @@ import stylistic from '@stylistic/eslint-plugin' +import sonarjs from 'eslint-plugin-sonarjs' import tseslint from 'typescript-eslint' /** @@ -123,6 +124,17 @@ export default tseslint.config( }, }, + // --- file-local duplication (all owned TypeScript) --------------------- + { + files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts'], + plugins: { sonarjs }, + rules: { + // Cross-file clones are covered separately by jscpd. + 'sonarjs/no-identical-functions': 'error', + 'sonarjs/no-duplicated-branches': 'error', + }, + }, + // --- formatting (everything we own) ------------------------------------- { files: ['packages/**/*.ts', 'examples/**/*.ts', 'scripts/**/*.ts', 'eslint.config.mjs'], diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml new file mode 100644 index 0000000000..64802e1da9 --- /dev/null +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless replay counterpart to advanced.cordis.yml. It composes the same +# Code Mode + Cordis + subagent + workflow tree while replacing only the live +# model adapter with the committed multi-session replay script. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml new file mode 100644 index 0000000000..772369578f --- /dev/null +++ b/examples/acp-agent/advanced.cordis.yml @@ -0,0 +1,25 @@ +# Advanced ACP snapshot overlay: keep every native tool on the wire, add the +# Code Mode worker, and opt into the self-referential Cordis toolset. The base +# tree already supplies direct spawn subagents and the workflow worker, so this +# composition exercises all four boundaries in one editor-facing ACP turn. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-agent' + config: + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + tools: + mode: both + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index b1a43b83db..6c3177ceef 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -26,6 +26,7 @@ const AGENT = { // replay swap resolves each one's sibling `*cordis.snapshot.yml`). const CODE_MODE_CONFIG = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) const BOTH_MODE_CONFIG = fileURLToPath(new URL('../both-mode.cordis.yml', import.meta.url)) +const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) function snapshotModeFromEnv(value: string | undefined): SnapshotSuiteOptions['mode'] { switch (value) { @@ -75,6 +76,20 @@ const SCENARIOS: Scenario[] = [ // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. { name: 'workflow-run', hasModelTurn: true, recorded: true }, + // ACP-facing counterpart to the packaged Python SDK snapshot: Cordis mounts + // a live marker, Code Mode inspects it through the worker bridge, then a + // direct child and a workflow child run before the mount is disposed. This + // class adds both Code Mode and the opt-in Cordis tools to the base tree, so + // it owns a distinct request-header pin. The scripted fixture is authored: + // the value is deterministic cross-boundary composition, not live-model prose. + { + name: 'advanced-toolchain', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'advanced', + configPath: ADVANCED_CONFIG, + }, // Hook matrix — one scenario per hook point × its headline Decision outcome, // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in // workspace/). The block scenarios need no model call: a UserPromptSubmit hook diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json new file mode 100644 index 0000000000..b66945d9a7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl new file mode 100644 index 0000000000..1eabba53b3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl new file mode 100644 index 0000000000..7787ed2ab1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl new file mode 100644 index 0000000000..bb74382b22 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl new file mode 100644 index 0000000000..2e9174f026 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -0,0 +1,14 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-code","status":"completed","content":[{"type":"content","content":{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-direct-child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Check direct child","prompt":"Reply with exactly DIRECT_CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-direct-child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DIRECT_CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-workflow","title":"workflow: advanced-acp-snapshot","kind":"other","status":"in_progress","rawInput":"phase('Delegate')\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\nreturn { reply }"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-workflow","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-unmount","title":"Unmount dyn-1","kind":"delete","status":"in_progress"}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-unmount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md new file mode 100644 index 0000000000..e70cfcce44 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -0,0 +1,151 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +## Writing code for run_code + +Pass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program: + +- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools["my-tool"](args)`. Every call resolves to the tool's text output as a string. Tool arguments must be JSON-serializable. +- A FAILED tool call rejects with an `Error` carrying the tool's error text — `try/catch` it to handle and continue. +- Calls execute sequentially, even under `Promise.all`. +- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need. + +The available tools: + +```ts +declare const tools: { + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. */ + bash(args: { + /** The bash command to execute. */ + command: string; + /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; "git status" → "Show working tree status"; "npm install" → "Install package dependencies". */ + description: string; + /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */ + timeoutMs?: number; + /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */ + workdir?: string; + /** Run in the background and return a task id immediately. No timeout applies. */ + run_in_background?: boolean; + }): Promise; + /** Ask the executor to kill a running background bash task by task id. */ + bash_kill(args: { + /** Task id returned by the bash tool. */ + task_id: string; + }): Promise; + /** Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output. */ + bash_output(args: { + /** Task id returned by the bash tool. */ + task_id: string; + }): Promise; + /** Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections. */ + cordis_inspect(args: { + /** Limit the report to one section. Omit for all sections. */ + what?: "services" | "plugins" | "tools" | "dynamic" | "api" | "events"; + }): Promise; + /** Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + cordis_mount(args: { + /** Body of an async JS function; must `return` the plugin to mount. */ + code: string; + }): Promise; + /** Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop). */ + cordis_unmount(args: { + /** The dynamic mount id returned by cordis_mount (e.g. "dyn-1"). */ + id: string; + }): Promise; + /** Edit an existing UTF-8 text file by replacing literal text. */ + edit(args: { + /** Path to edit, resolved by the filesystem backend. */ + file_path: string; + /** Literal text to replace. Must match exactly. */ + old_string: string; + /** Literal replacement text. Use an empty string to delete the match. */ + new_string: string; + /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */ + replace_all?: boolean; + }): Promise; + /** Read a UTF-8 text file and return line-numbered content. */ + read(args: { + /** Path to read, resolved by the filesystem backend. */ + file_path: string; + /** 1-based first line to return. Defaults to 1. */ + offset?: number; + /** Maximum number of lines to return. Defaults to 2000. */ + limit?: number; + }): Promise; + /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */ + skill(args: { + /** The exact skill name from the available skills list. */ + name: string; + }): Promise; + /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. */ + subagent(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */ + prompt: string; + }): Promise; + /** Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. */ + subagent_fork(args: { + /** A short (3-5 word) description of the delegated task, for display. */ + description: string; + /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */ + prompt: string; + }): Promise; + /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */ + todo_write(args: { + /** The COMPLETE task list, replacing any previous list. */ + todos: ({ + /** What the task is — a short imperative line. */ + content: string; + /** pending (not started) | in_progress (now) | completed (done). */ + status: "pending" | "in_progress" | "completed"; + })[]; + }): Promise; + /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */ + workflow(args: { + /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */ + script: string; + /** The workflow identity block (plain JSON — never code). */ + meta: { + /** Short kebab-case workflow name. */ + name: string; + /** One-line description of what the workflow does. */ + description: string; + /** Optional guidance on when this workflow applies. */ + whenToUse?: string; + /** Optional phase declarations matched by phase() calls. */ + phases?: { + /** The phase title phase() calls match by exact string. */ + title: string; + /** Optional one-line description of the phase. */ + detail?: string; + /** Optional model override this phase is expected to use. */ + model?: string; + }[]; + }; + /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}). */ + args?: Record; + }): Promise; + /** Create or fully replace a UTF-8 text file. */ + write(args: { + /** Path to write, resolved by the filesystem backend. */ + file_path: string; + /** Full UTF-8 text content to write. */ + content: string; + }): Promise; +} +``` diff --git a/knip.json b/knip.json index cf43b90e34..825980f205 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], "ignoreBinaries": ["bwrap", "sandbox-exec"], - "ignoreWorkspaces": ["vendor/*"], + "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { ".": { "entry": [ @@ -81,6 +81,9 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/jsonrpc-agent": { + "project": ["src/**/*.ts"] + }, "packages/subagent/subagent-spawn": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] diff --git a/package.json b/package.json index ed0b603feb..b453bf3791 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc -b tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", + "duplication": "jscpd --config .jscpd.json packages", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:e2e": "vitest run --config vitest.e2e.config.ts", @@ -45,6 +46,7 @@ "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", + "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -64,7 +66,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", @@ -81,7 +83,9 @@ "@types/node": "^22.20.0", "@vitest/coverage-v8": "^4.1.8", "eslint": "^10.4.1", + "eslint-plugin-sonarjs": "^4.1.0", "fast-check": "^4.8.0", + "jscpd": "^5.0.12", "jsdom": "29.1.1", "knip": "^6.16.1", "lefthook": "^2.1.9", diff --git a/packages/README.md b/packages/README.md index 35c4652a1f..7b6b1bb32a 100644 --- a/packages/README.md +++ b/packages/README.md @@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | -| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | +| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded` primitive) | Support — small, stable, harness-dep-free | diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 38576b6f28..12b4a53958 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -102,16 +102,7 @@ async function callUntilText( throw new Error(`${name} output did not include ${JSON.stringify(expected)}; last text was ${JSON.stringify(last !== undefined ? text(last) : '')}`) } -class LossyReadBashExecutor extends BashExecutor { - private readonly task: BashTask = { - id: BashTaskId('bash-lossy'), - command: 'fake', - status: 'running', - exitCode: null, - signal: null, - done: Promise.resolve(), - } - +abstract class TestBashExecutor extends BashExecutor { resolve(request: BashExecRequest): BashExecSpec { return { command: request.command, @@ -122,6 +113,17 @@ class LossyReadBashExecutor extends BashExecutor { sandboxMode: request.sandboxMode, } } +} + +class LossyReadBashExecutor extends TestBashExecutor { + private readonly task: BashTask = { + id: BashTaskId('bash-lossy'), + command: 'fake', + status: 'running', + exitCode: null, + signal: null, + done: Promise.resolve(), + } run(): Promise { return Promise.reject(new Error('not used')) @@ -1057,7 +1059,7 @@ describe('sandbox rendering', () => { // it anyway: an executor that reports no sandboxMode (fields never // advertised) whose task nonetheless carries denial facts must render // the marker without suggesting a lever the schema does not offer. - class FactsOnlyExecutor extends BashExecutor { + class FactsOnlyExecutor extends TestBashExecutor { private readonly task: BashTask = { id: BashTaskId('bash-facts'), command: 'fake', @@ -1068,17 +1070,6 @@ describe('sandbox rendering', () => { sandbox: { mode: 'read-only', denied: true }, } - resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? process.cwd(), - timeoutMs: request.timeoutMs ?? 0, - ...request.signal ? { signal: request.signal } : {}, - owner: request.owner, - sandboxMode: request.sandboxMode, - } - } - run(): Promise { return Promise.reject(new Error('not used')) } start(): BashTask { return this.task } get(id: string): BashTask | undefined { return id === this.task.id ? this.task : undefined } diff --git a/packages/code-runtime/code-runtime-worker/README.md b/packages/code-runtime/code-runtime-worker/README.md index f691904e88..8138f01539 100644 --- a/packages/code-runtime/code-runtime-worker/README.md +++ b/packages/code-runtime/code-runtime-worker/README.md @@ -29,4 +29,4 @@ Every field is validated (positive numbers) and defaulted; there are no other tu ## The worker entry, unbuilt and built -`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle `lib/worker.js` (its own tsdown entry). The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). +`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling CommonJS bundle `lib/worker.cjs` (its own tsdown entry). The CommonJS format is required because pkg's VFS Worker hook compiles filesystem-string entries as CommonJS. The host converts either entry URL to a filesystem string before constructing `Worker`, which works through both ordinary Node resolution and that pkg hook. The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md). diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 027089337b..91075243d2 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -13,14 +13,14 @@ }, "./worker": { "types": "./lib/types/worker.d.ts", - "default": "./lib/worker.js" + "default": "./lib/worker.cjs" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/worker.js", + "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index f78f06cb0b..7a8024cb3d 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -14,6 +14,7 @@ import { Worker } from 'node:worker_threads' import { stripTypeScriptTypes } from 'node:module' +import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' @@ -98,16 +99,19 @@ interface LiveRun { } /** - * The worker entry module. Source runs unbuilt (`src/worker.ts`, loadable + * The worker entry path. Source runs unbuilt (`src/worker.ts`, loadable * directly on this repo's Node range via native type stripping — the file * is erasable-only with type-only relative imports); the built package - * ships it as a sibling bundle (`lib/worker.js`, its own tsdown entry). + * ships it as a sibling CommonJS bundle (`lib/worker.cjs`, its own tsdown + * entry) because pkg's VFS Worker hook compiles string-path entries as + * CommonJS. * The URL *pathname*'s extension says which world this module is in — * pathname, because dev-time module runners (vitest) may suffix - * `import.meta.url` with a query string; relative resolution drops it. + * `import.meta.url` with a query string; relative resolution drops it. Worker + * receives a filesystem string so pkg's VFS Worker hook can resolve it. */ -/* v8 ignore next -- the './worker.js' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */ -const WORKER_URL = new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url) +/* v8 ignore next -- the './worker.cjs' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */ +const WORKER_PATH = fileURLToPath(new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.cjs', import.meta.url)) /** Render an unknown thrown value as a message, `Error` or not. */ function messageOf(error: unknown): string { @@ -273,7 +277,7 @@ export class WorkerCodeRuntime extends CodeRuntime { maxLogBytes: this.config.maxLogBytes, maxValueBytes: this.config.maxValueBytes, } - const worker = new Worker(WORKER_URL, { + const worker = new Worker(WORKER_PATH, { workerData: bootData, // Model code gets NO ambient environment — stronger than the scrubbed // env the defensive-patterns rule requires for spawned commands. diff --git a/packages/code-runtime/code-runtime-worker/src/worker.ts b/packages/code-runtime/code-runtime-worker/src/worker.ts index efaafdb038..9c9b009b4e 100644 --- a/packages/code-runtime/code-runtime-worker/src/worker.ts +++ b/packages/code-runtime/code-runtime-worker/src/worker.ts @@ -17,4 +17,4 @@ import type { WorkerBootData } from './protocol.ts' // A worker always has a parent port; guard loudly rather than run detached. if (!parentPort) throw new Error('dsh-code-runtime-worker: worker entry loaded outside a worker thread') -await runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr }) +void runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr }) diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 66ce1830b6..aac0ece8a1 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -8,7 +8,7 @@ import { describe, expect, it } from 'vitest' * BUILT-ARTIFACT smoke for the published package (the real-load-path guard * from docs/testing.md): the unit suite runs `src/` under vitest, where the * worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js` - * under plain `node`, where it must resolve the sibling `lib/worker.js` + * under plain `node`, where it must resolve the sibling `lib/worker.cjs` * bundle instead. This spawns plain `node` (NOT tsx) from inside the package * directory and imports the package BY NAME, so resolution flows through the * real `exports` map exactly as it would from a downstream install; the @@ -21,11 +21,11 @@ import { describe, expect, it } from 'vitest' */ const pkgDir = fileURLToPath(new URL('..', import.meta.url)) -const built = ['lib/index.js', 'lib/worker.js'].every(file => existsSync(join(pkgDir, file))) +const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(pkgDir, file))) && existsSync(join(pkgDir, '../code-runtime/lib/index.js')) describe.skipIf(!built)('built lib real load path (plain node)', () => { - it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.js entry', async () => { + it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => { const script = ` const { Context } = await import('cordis') const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker') diff --git a/packages/code-runtime/code-runtime-worker/tsdown.config.ts b/packages/code-runtime/code-runtime-worker/tsdown.config.ts index 5af39d936a..3eec074119 100644 --- a/packages/code-runtime/code-runtime-worker/tsdown.config.ts +++ b/packages/code-runtime/code-runtime-worker/tsdown.config.ts @@ -3,8 +3,10 @@ import { defineConfig } from 'tsdown' /** * Package-shape override (see the root tsdown.config.ts): besides the * default lib/index.js bundle, the worker BOOTSTRAP ships as its own - * sibling entry — `new Worker(new URL('./worker.js', import.meta.url))` - * loads it as a file, so it cannot be part of the index bundle. TWO + * sibling CommonJS entry — `new Worker(fileURLToPath(new URL('./worker.cjs', import.meta.url)))` + * loads it as a file, so it cannot be part of the index bundle. pkg's VFS + * Worker hook compiles string-path entries as CommonJS, so an ESM worker is + * not viable inside the executable. TWO * single-entry builds, not one two-entry build: a multi-entry build emits * the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles * import, which the package.json `files` whitelist (deliberately exact) @@ -25,7 +27,7 @@ export default defineConfig([ { entry: ['lib/types/worker.js'], outDir: 'lib', - format: ['esm'], + format: ['cjs'], platform: 'node', target: 'es2024', fixedExtension: false, diff --git a/packages/hooks/hooks-codex/src/config.ts b/packages/hooks/hooks-codex/src/config.ts index 505f8d2bda..278dae96e3 100644 --- a/packages/hooks/hooks-codex/src/config.ts +++ b/packages/hooks/hooks-codex/src/config.ts @@ -53,6 +53,9 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { for (const event of CODEX_EVENTS) { const rawGroups = hooksMap[event] + // Matcher-group parsing remains dialect-local because the supported hook + // shapes and skip reasons differ from Claude Code's. + /* jscpd:ignore-start */ if (!Array.isArray(rawGroups)) continue const groups: MatcherGroup[] = [] for (const rawGroup of rawGroups) { @@ -64,6 +67,7 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig { if (!hook) continue const type = typeof hook.type === 'string' ? hook.type : 'command' if (type !== 'command') { skipped.push({ event, reason: `unsupported "${type}" hook` }); continue } + /* jscpd:ignore-end */ if (hook.async === true) { skipped.push({ event, reason: 'async hook' }); continue } if (typeof hook.command !== 'string') continue // Codex accepts `timeout` or the `timeoutSec` alias. diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index e164d98a70..3347447d86 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -153,6 +153,9 @@ export function apply(ctx: Context, config: Config): void { output.additionalContext = output.stdout } outputs.push(output) + // Execution and decision mapping remain in each bridge so dialect + // differences stay explicit at their owning seam. + /* jscpd:ignore-start */ if (output.systemMessage !== undefined) { ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } @@ -202,12 +205,14 @@ export function apply(ctx: Context, config: Config): void { if (context) agent.inject(context.content, { source: context.source }) }) .catch((error: unknown) => { ctx.logger.warn(`hooks-codex: SessionStart hook failed: ${String(error)}`) })) + /* jscpd:ignore-end */ }) // UserPromptSubmit → PromptDecision. Codex can only BLOCK (no allow/ask). ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise => { const turn = lastTurn(agent) const merged = await runPoint('UserPromptSubmit', '', { ...turnBase(agent, 'UserPromptSubmit', model), prompt: blocksToText(content) }, { agent, turn, plainStdoutAsContext: true }) + /* jscpd:ignore-start */ if (merged.decision === 'deny') return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' } // Context alone is not a veto: DELEGATE so a later prompt-submit listener can // still block/rewrite, then fold our context onto its decision. @@ -225,6 +230,7 @@ export function apply(ctx: Context, config: Config): void { ctx.on('tools/pre-execute', async (exec, next): Promise => { const turn = lastTurn(exec.agent) const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) + /* jscpd:ignore-end */ if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' } return next() }) @@ -232,6 +238,7 @@ export function apply(ctx: Context, config: Config): void { // PostToolUse → PostToolDecision (block with feedback, or attach context). ctx.on('tools/post-execute', async (exec, result, next): Promise => { const turn = lastTurn(exec.agent) + /* jscpd:ignore-start */ const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result, model), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} }) const context = contextFrom(merged) if (merged.decision === 'deny') { @@ -257,6 +264,7 @@ export function apply(ctx: Context, config: Config): void { // loop-guard (stop_hook_active + a max-consecutive cap) is deferred. ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise => { const merged = await runPoint('Stop', '', { ...turnBase(agent, 'Stop', model), stop_hook_active: false, last_assistant_message: null }, { agent, turn }) + /* jscpd:ignore-end */ if (merged.decision === 'deny') { // A blocking Stop hook forces continuation; a block with no reason (exit 2, // empty stderr) still forces it — fall back to a generic steering line @@ -271,6 +279,9 @@ export function apply(ctx: Context, config: Config): void { // --- Codex DIALECT payloads: snake_case, model on every event, turn_id on // turn-scoped events. --- +// These small payload helpers intentionally remain next to the dialect shape; +// sharing them would pull bridge-only agent/LLM dependencies into hook-protocol. +/* jscpd:ignore-start */ function lastTurn(agent: Agent | undefined): number { if (!agent) return 0 const last = [...agent.session.events].findLast(e => e.type === 'turn/start') @@ -283,6 +294,7 @@ function lastTurn(agent: Agent | undefined): number { function blocksToText(content: ContentBlock[]): string { return content.filter((b): b is Extract => b.type === 'text').map(b => b.text).join('') } +/* jscpd:ignore-end */ /** Base fields on every Codex payload (no turn_id). */ function base(agent: Agent | undefined, event: string, model: string): Record { diff --git a/packages/session-persistence/session-persistence-jsonl/src/index.ts b/packages/session-persistence/session-persistence-jsonl/src/index.ts index 5928332b81..dc9dcd4615 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/index.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/index.ts @@ -88,6 +88,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi this.coordinator = new PersistenceCoordinator(this.ctx, this) } + // Each backend keeps the typed service surface beside its storage hooks; + // extracting these trivial forwards would add an inheritance seam. + /* jscpd:ignore-start */ // --- SessionPersistence service surface (delegated to the coordinator) --- create(meta: SessionHeader): Promise { @@ -108,6 +111,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi // this same method; routing it through the coordinator would recurse. Defined // once, in the "PersistenceBackend hooks" section. + /* jscpd:ignore-end */ // --- PersistenceBackend hooks (the file-bytes storage primitives) --- /** Read a stored prefix by id across ALL cwd buckets (cwd unknown). */ diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 87cc62d165..3b4e8c5fee 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -54,6 +54,16 @@ function chunkEvent(seq: number, turn: number, step: number, chunk: StreamChunk) let dir: string let file: string +/** Write a session log file and return its path. */ +function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { + let seq = 1 + const events: SessionEvent[] = [] + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + const path = join(dir, filename) + writeFileSync(path, sessionJsonl(events, header), 'utf8') + return path +} + beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'llm-replay-spec-')) file = join(dir, 'session.jsonl') @@ -391,16 +401,6 @@ describe('parseSessionHeader', () => { }) describe('loadSessionScripts', () => { - /** Write a session log file and return its path. */ - function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { - let seq = 1 - const events: SessionEvent[] = [] - calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) - const path = join(dir, filename) - writeFileSync(path, sessionJsonl(events, header), 'utf8') - return path - } - it('returns one primary script for a single-session scenario', () => { const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS]) const scripts: SessionScript[] = loadSessionScripts({ file: f }) @@ -508,16 +508,6 @@ describe('installLlmReplay (per-session keying)', () => { { type: 'finish', reason: { kind: 'stop' } }, ] - /** Write a session log file and return its path. */ - function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { - let seq = 1 - const events: SessionEvent[] = [] - calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) - const path = join(dir, filename) - writeFileSync(path, sessionJsonl(events, header), 'utf8') - return path - } - const live = (id: string): GenerateOptions => ({ model: 'm', messages: [], sessionId: id as NonNullable }) diff --git a/packages/ui/README.md b/packages/ui/README.md index 604eff6521..4f5a6dc920 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -10,10 +10,12 @@ Integrations that expose the agent to an external editor or client. These are ** | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `jsonrpc/` | Stdio JSON-RPC SDK server plugin: serves `HarnessSdkServer` to out-of-process SDK clients (the Python SDK) on the process stdio | (drives `ctx.agents`) | +| `jsonrpc-agent/` | JSON-RPC SDK server APP: a bin-only boot of an external `cordis.yml` whose `jsonrpc` entry is the serving face; the single-exe runtime entrypoint | (`bin` only) | +| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. +`stdio-agent` and `acp-agent` are the two composing **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. `jsonrpc-agent` is the third app but bin-only — no composition plugin, because the SDK runtime's hard semantic is that the external `cordis.yml` composes everything, the serving `jsonrpc` entry included. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index cb3eab3545..0a9cf3e82e 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -30,6 +30,21 @@ function registryOf(...tools: ToolDefinition[]): Pick { return { get: name => map.get(name) } } +function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { + const out: SessionNotification['update'][] = [] + for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) + return out +} + +async function fsCtx(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FsLocal) + await ctx.plugin(ToolFs) + return ctx +} + function evt(type: T, data: Extract['data']): SessionEvent { return { type, seq: 0, time: 0, data } as SessionEvent } @@ -181,12 +196,6 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () => }), } - function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { - const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) - return out - } - it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => { const presenter = new ToolPresenter(registryOf(bashLike)) const [update] = updatesWith(presenter, evt('tool/call', { @@ -627,21 +636,6 @@ describe('result-time diff card (REAL fs edit tool → tool_call_update diff blo // result card the bridge forwards as `{ type: 'diff' }` content blocks. Uses // the REAL tool (not a stand-in) per the anti-mock convention, mirroring the // call-side diff test above. - async function fsCtx(): Promise { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(FsLocal) - await ctx.plugin(ToolFs) - return ctx - } - - function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] { - const out: SessionNotification['update'][] = [] - for (const event of events) streamSessionEventUpdate(SessionId('s1'), event, n => out.push(n.update), presenter) - return out - } - it('forwards the applied-hunk meta onto the wire as tool_call_update diff content', async () => { const ctx = await fsCtx() const presenter = new ToolPresenter(ctx.tools) @@ -739,14 +733,6 @@ describe('relative-path display titles (bridge relativizes the title against the // diff paths RAW. Drive it with the REAL fs tools so the title/locations come // from the shipping presentCall, and pass an ABSOLUTE file path (which a real // editor forwards). The presenter is pure/args-only; the cwd is known only here. - async function fsCtx(): Promise { - const ctx = new Context() - await ctx.plugin(SystemPrompt) - await ctx.plugin(ToolRegistry) - await ctx.plugin(FsLocal) - await ctx.plugin(ToolFs) - return ctx - } function callUpdate(ctx: Context, sessionCwd: string | undefined, name: string, args: unknown): SessionNotification['update'] { const presenter = new ToolPresenter(ctx.tools) const out: SessionNotification['update'][] = [] diff --git a/packages/ui/jsonrpc-agent/README.md b/packages/ui/jsonrpc-agent/README.md new file mode 100644 index 0000000000..d1836c737d --- /dev/null +++ b/packages/ui/jsonrpc-agent/README.md @@ -0,0 +1,17 @@ +# @deepseek-ai/dsh-jsonrpc-agent + +The **JSON-RPC SDK server app bin** (`dsh-jsonrpc-agent`): boot a harness from an externally supplied `cordis.yml` and let its [`@deepseek-ai/dsh-jsonrpc`](../jsonrpc/README.md) entry serve SDK clients over newline-delimited JSON-RPC on stdio. Structurally the SDK-runtime sibling of [`acp-agent`](../acp-agent/README.md)'s bin, but bin-only: there is no composition plugin here, because "the plugins that actually start come from the external config" is the SDK runtime's hard semantic — the leaf `cordis.yml` composes the spine, the backends, AND the serving face. This package is the entrypoint of the single-exe distribution (its `lib/bin.js` is what the packaged executable runs) — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Config discovery + +Two channels, environment first: `$DSH_CORDIS_CONFIG` (the existing SDK-client convention, wins), then the `argv[2]` positional path (`dsh-jsonrpc-agent `, the human channel, isomorphic to `dsh-acp-agent`). An empty value counts as absent on either channel. Neither given, or the path missing on disk: the bin prints a one-line usage naming both channels to stderr and exits 1 — there is no default `./cordis.yml` and no built-in fallback config. A config that names a plugin which fails to load fails loud through the shared [`dsh-app-boot`](../app-boot/README.md) guards (`assertEntriesLoaded` + the unhandled-rejection handler), never a silent half-boot. There is no `DSH_SNAPSHOT` handling: this protocol is not part of the ACP snapshot tier. + +Note the deliberate flip side of config-decides-everything: a config that loads no `dsh-jsonrpc` entry boots fine and serves nothing — the bin cannot know which plugin is "the server". + +## Exit lifecycle + +The bin owns the PROCESS-level exits: stdin EOF (the SDK client is gone — an in-flight turn is deliberately cut off, see the risk note in docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) and `SIGTERM` dispose the root context to quiescence and exit 0; `SIGINT` does the same but exits 130. The PROTOCOL-level exit — a `shutdown` JSON-RPC request answered first, then exit 0 — is owned by the `dsh-jsonrpc` plugin, which holds the server and transport; the two paths are individually idempotent and safe to race. + +## stdout is the protocol + +stdout carries only JSON-RPC frames; the bin and the app-boot guards write diagnostics to stderr only, and the booted config must load no stdout logger (see the `dsh-jsonrpc` README). diff --git a/packages/ui/jsonrpc-agent/package.json b/packages/ui/jsonrpc-agent/package.json new file mode 100644 index 0000000000..aebff4ab3a --- /dev/null +++ b/packages/ui/jsonrpc-agent/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-jsonrpc-agent", + "description": "JSON-RPC SDK server app bin: boots an externally supplied cordis.yml (DSH_CORDIS_CONFIG or argv, no built-in fallback) whose dsh-jsonrpc entry serves SDK clients over stdio; the single-exe runtime entrypoint", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "bin": { + "dsh-jsonrpc-agent": "lib/bin.js" + }, + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./bin": { + "types": "./lib/types/bin.d.ts", + "default": "./lib/bin.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "@deepseek-ai/dsh-app-boot": "workspace:^" + }, + "peerDependencies": { + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/jsonrpc-agent/src/bin.ts b/packages/ui/jsonrpc-agent/src/bin.ts new file mode 100644 index 0000000000..b2c9f0c3d2 --- /dev/null +++ b/packages/ui/jsonrpc-agent/src/bin.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env node +/** + * The `dsh-jsonrpc-agent` bin: boot a harness from an externally supplied + * `cordis.yml` whose `@deepseek-ai/dsh-jsonrpc` entry serves SDK clients over + * newline-delimited JSON-RPC on stdio. The shared boot glue — `.env` loading, + * the fail-loud Loader guards, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}, shared with the stdio/ACP bins; this bin + * owns only config discovery and the process-level exit lifecycle: + * + * - Config discovery is `$DSH_CORDIS_CONFIG` (the existing SDK-client + * convention, wins) or the `argv[2]` positional path (the human channel, + * isomorphic to `dsh-acp-agent`); an empty value counts as absent. Neither + * given, or the path missing on disk, prints the one-line usage to stderr + * and exits 1. No built-in fallback — the external config IS the deployment + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * No `DSH_SNAPSHOT` handling: this + * protocol is not part of the ACP snapshot tier. + * - stdin EOF (the SDK client is gone) and SIGTERM dispose the root context + * to quiescence and exit 0; SIGINT does the same but exits 130. The + * `shutdown` JSON-RPC request's answer-then-exit-0 path is owned by the + * `dsh-jsonrpc` plugin, which holds the server (see its README). + * + * IMPORTANT: stdout is the JSON-RPC channel. Diagnostics go to STDERR only (a + * stray stdout write corrupts the protocol frames), which the app-boot guards + * already honor. + * + * @module @deepseek-ai/dsh-jsonrpc-agent/bin + */ + +import { existsSync } from 'node:fs' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +const NAME = 'dsh-jsonrpc-agent' + +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; the serving lifecycle it boots is unit-tested in + @deepseek-ai/dsh-jsonrpc, and the composed artifact is exercised by the + single-exe acceptance drive (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) */ +installFailLoud(NAME) +loadEnv(NAME) + +// Env wins over the positional argument; an empty value on either channel +// counts as absent. There is deliberately NO default `./cordis.yml`: "the +// plugins that actually start come from an explicit external config" is a +// hard semantic of the SDK runtime. +const fromEnv = process.env['DSH_CORDIS_CONFIG'] +const fromArgv = process.argv[2] +const requested = fromEnv !== undefined && fromEnv !== '' + ? fromEnv + : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined +const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) +if (configPath === undefined || !existsSync(configPath)) { + process.stderr.write( + `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, + ) + process.exit(1) +} + +const ctx = await boot(NAME, configPath) +let exiting = false + +async function disposeAndExit(code: number): Promise { + if (exiting) return + exiting = true + try { + await ctx.fiber.dispose() + } finally { + process.exit(code) + } +} + +process.stdin.on('end', () => { void disposeAndExit(0) }) +process.on('SIGTERM', () => { void disposeAndExit(0) }) +process.on('SIGINT', () => { void disposeAndExit(130) }) +/* v8 ignore stop */ diff --git a/packages/ui/jsonrpc-agent/src/index.ts b/packages/ui/jsonrpc-agent/src/index.ts new file mode 100644 index 0000000000..39a2206dca --- /dev/null +++ b/packages/ui/jsonrpc-agent/src/index.ts @@ -0,0 +1,14 @@ +/** + * The `dsh-jsonrpc-agent` app package IS its bin (see `./bin.ts`): config + * discovery plus the process-level exit lifecycle around a booted + * `cordis.yml`. This module deliberately exports nothing — unlike the + * stdio/ACP app packages there is no composition plugin here, because the + * serving face is the {@link @deepseek-ai/dsh-jsonrpc} plugin the external + * config loads like any other entry (which plugins actually start is the + * config's decision, the hard semantic of the SDK runtime; see + * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * @module @deepseek-ai/dsh-jsonrpc-agent + */ + +export {} diff --git a/packages/ui/jsonrpc-agent/tsconfig.json b/packages/ui/jsonrpc-agent/tsconfig.json new file mode 100644 index 0000000000..e76defe8e2 --- /dev/null +++ b/packages/ui/jsonrpc-agent/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../app-boot" + } + ] +} diff --git a/packages/ui/jsonrpc-agent/tsdown.config.ts b/packages/ui/jsonrpc-agent/tsdown.config.ts new file mode 100644 index 0000000000..5b09ae2704 --- /dev/null +++ b/packages/ui/jsonrpc-agent/tsdown.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'tsdown' + +/** + * jsonrpc-agent ships TWO entries: the doc-only module (`index`) and the CLI + * `bin` (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. + * The root tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. + */ +export default defineConfig({ + entry: ['lib/types/index.js', 'lib/types/bin.js'], + outDir: 'lib', + format: ['esm'], + platform: 'node', + target: 'es2024', + fixedExtension: false, + dts: false, + clean: false, +}) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md new file mode 100644 index 0000000000..007bd1a5f1 --- /dev/null +++ b/packages/ui/jsonrpc/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-jsonrpc + +The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize` → `session/prompt` → `shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, [`jsonrpc-agent`](../jsonrpc-agent/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the single-exe distribution plan — see [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Wiring + +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`) and demuxes `subagent/end` through the registry. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. + +## Config + +No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`. + +## stdout is the protocol + +The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr. + +## Shutdown and exit semantics + +The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process. + +## Wire notes + +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json new file mode 100644 index 0000000000..be19ab7217 --- /dev/null +++ b/packages/ui/jsonrpc/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-jsonrpc", + "description": "Stdio JSON-RPC SDK server plugin: serves HarnessSdkServer over newline-delimited JSON-RPC on the process stdio, letting an out-of-process SDK client (e.g. the Python SDK) drive DeepSeek Harness agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.17.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/jsonrpc/src/index.ts b/packages/ui/jsonrpc/src/index.ts new file mode 100644 index 0000000000..624c8db824 --- /dev/null +++ b/packages/ui/jsonrpc/src/index.ts @@ -0,0 +1,144 @@ +/** + * The SDK-facing stdio JSON-RPC server plugin: mounting it wires a + * {@link JsonRpcLineTransport} over the process stdio and serves + * {@link HarnessSdkServer} (`initialize` → `session/prompt`* → `shutdown`, + * plus the `session.*`/`subagent.*` notifications) to an out-of-process SDK + * client (e.g. the Python `deepseek_harness` package). The structured + * SDK-client analogue of the `acp` bridge: a client-driver plugin over + * `ctx.agents`, not a loop change and not a capability seam. Which process + * actually serves this protocol is a `cordis.yml` decision — the tree that + * loads this plugin IS the SDK server (the `dsh-jsonrpc-agent` bin boots such + * a tree for the single-exe distribution; see + * docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * stdout is the protocol: this plugin must run in a tree that loads NO stdout + * logger (the console logger writes to stdout and would corrupt the JSON-RPC + * frames). The guarantee is config-only — see the package README. + * + * Exit-lifecycle split: this plugin owns the PROTOCOL-level exit (the + * `shutdown` request answers first, then the plugin disposes its own fiber and + * exits 0 — see {@link apply}); process-level exits (stdin EOF, SIGTERM, + * SIGINT) belong to the app bin (`dsh-jsonrpc-agent`), which disposes the + * whole root context. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default + * export — the cordis Loader's `unwrapExports` does `exports.default ?? + * exports`, so a stray default would collapse the module to the bare `apply` + * and silently drop `inject`/`name`/`Config` (see docs/postmortem/0001). + * + * @module @deepseek-ai/dsh-jsonrpc + */ + +import type { Context } from 'cordis' +import type { Readable, Writable } from 'node:stream' +import Schema from 'schemastery' +import { HarnessSdkServer } from './server.ts' +import { JsonRpcLineTransport } from './transport.ts' + +export * from './server.ts' +export * from './transport.ts' + +export const name = 'jsonrpc' +// The server programs against the agent factory only: `agents` is read on +// every `session/prompt` (get-or-create) and on `subagent/end` demux. The LLM +// seam is deliberately NOT injected — `initialize` reads it opportunistically +// via `ctx.get('llm')` (the topology-independent lookup for a non-injected +// service, per packages/AGENTS.md) to decide whether to lazily mount the +// DeepSeek adapter for the requested model. +export const inject = ['agents'] + +/** + * Plugin config. Every field is a runtime-only test seam — none is part of the + * schemastery {@link Config}, so nothing here is settable from a `cordis.yml` + * (production always serves the process stdio and exits via `process.exit`). + */ +export interface JsonRpcConfig { + /** + * Transport input override. Production omits this (the plugin reads + * `process.stdin`); tests inject an in-memory `Readable` to drive the server + * without a subprocess. + */ + input?: Readable + /** + * Transport output override. Production omits this (the plugin writes + * `process.stdout` — the protocol channel); tests inject an in-memory + * `Writable` to capture frames. + */ + output?: Writable + /** + * Process-exit override for the `shutdown` request path. Production omits + * this (`process.exit`); tests inject a recorder so a driven shutdown does + * not kill the test process. + */ + exit?: (code: number) => void +} + +export const Config: Schema = Schema.object({}) + +/** + * Mount the SDK server on the process stdio: build the line transport and + * {@link HarnessSdkServer}, dispatch incoming requests, and start reading + * frames. Disposal is an effect: disposing this plugin's fiber runs + * `server.shutdown()` (disposes every SDK-created agent to quiescence and + * detaches the event subscriptions) and `transport.close()`. + * + * The `shutdown` request's process-exit semantics live HERE, because the + * plugin owns the server and transport: the request is answered first, an + * explicit output-write barrier confirms the response frame flushed, then the + * plugin disposes its + * OWN fiber and calls `exit(0)`. Own-fiber disposal is sufficient — the + * request's `server.shutdown()` already brought every SDK-created agent to + * quiescence (their session logs are flushed by the awaited agent-handle + * disposes), the fiber's effect disposer re-runs the idempotent shutdown and + * closes the transport, and the process exit that follows IS the teardown of + * the rest of the tree (the bin's EOF/signal handlers own root-context + * disposal for the process-level exits). + */ +export function apply(ctx: Context, config: JsonRpcConfig): void { + // Capture the fiber handle NOW, during apply(): the shutdown path runs LATER, + // from the transport's read loop, and must dispose exactly this plugin's + // fiber (cf. the injection-scope capture note in the acp bridge). + const fiber = ctx.fiber + /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ + const input = config.input ?? process.stdin + /* v8 ignore next -- production stdio wiring; tests always inject the runtime seams */ + const output = config.output ?? process.stdout + /* v8 ignore next -- production exit wiring; tests always inject the runtime seams */ + const exit = config.exit ?? ((code: number): void => { process.exit(code) }) + + const transport = new JsonRpcLineTransport(input, output) + const server = new HarnessSdkServer(ctx, transport) + + // The shutdown-request exit path, exactly once (a second `shutdown` frame + // racing the dispose shares the same task). Flush and disposal failures are + // settled independently: once shutdown was answered, process exit is still + // the honest outcome and neither failure may prevent the next teardown step. + let exitTask: Promise | undefined + const disposeAndExit = (): Promise => { + exitTask ??= (async () => { + await Promise.allSettled([Promise.resolve().then(() => transport.flush())]) + await Promise.allSettled([Promise.resolve().then(() => fiber.dispose())]) + exit(0) + })() + return exitTask + } + + transport.onRequest(async (method, params) => { + const result = await server.handleRequest(method, params) + if (method === 'shutdown') { + // The transport writes the returned result after this handler resolves. + // Schedule the explicit flush barrier after that write, then dispose this + // plugin's fiber and exit 0 (see apply's doc). + setImmediate(() => { void disposeAndExit() }) + } + return result + }) + + ctx.effect(() => { + transport.start() + return async () => { + await server.shutdown() + transport.close() + } + }, 'jsonrpc.serve') +} diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts new file mode 100644 index 0000000000..86af6645f3 --- /dev/null +++ b/packages/ui/jsonrpc/src/server.ts @@ -0,0 +1,276 @@ +/** + * `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin + * serves to out-of-process SDK clients (e.g. the Python `deepseek_harness` + * package). Requests: `initialize` → `session/prompt`* → `shutdown`. + * Notifications pushed to the host: `session.event` (every durable session + * event, verbatim), `session.finished` (per prompt turn settle), + * `subagent.started` / `subagent.finished` (child-session lineage and run + * outcomes). The server owns only the SDK-facing session map — the harness + * itself is the context the plugin mounts in; plugins, persistence, and + * the LLM adapter set all come from the external `cordis.yml`. + * + * @module @deepseek-ai/dsh-jsonrpc/server + */ + +import type { Context } from 'cordis' +import { resolve } from 'node:path' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import type { JsonRpcTransportPeer } from './transport.ts' + +/** Parameters of the `initialize` request (once per process, before any prompt). */ +export interface InitializeParams { + /** Working directory recorded on every SDK-created session's header. */ + cwd: string + /** Model name every SDK-created agent runs on (see {@link HarnessSdkServer.initialize} for adapter fallback). */ + model: string +} + +/** Result of the `initialize` request: the server's identity for the SDK handshake. */ +export interface InitializeResult { + /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ + serverInfo: { name: string; version: string } +} + +/** + * Parameters of a `session/prompt` request: one user turn on one SDK session, + * with at most one in flight per session. + */ +export interface SessionPromptParams { + /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ + sessionId: string + /** The prompt content blocks, sent verbatim as the user message. */ + contentBlocks: ContentBlock[] +} + +/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */ +export interface SessionPromptResult { + /** Always `true`; the turn outcome is the paired `session.finished` notification. */ + accepted: true +} + +interface SessionRecord { + handle: AgentHandle + lastTurnEnd: TurnEndReason | undefined + activePrompt: boolean +} + +interface SubagentRecord { + childSessionId: string + parentSessionId: string | undefined +} + +/** + * The SDK server over a booted harness context. Constructing it subscribes to + * the context's `session/event`, `session/created`, `agent/created`, and + * `subagent/end` events and forwards them to the host as notifications; the + * subscriptions live until {@link shutdown}. One instance serves one transport + * peer for the process lifetime — there is no re-`initialize`. + */ +export class HarnessSdkServer { + private cwd = process.cwd() + private model = 'deepseek' + private llmFiber: { dispose(): Promise } | undefined + private readonly sessions = new Map() + private readonly sessionCreations = new Map>() + private readonly subagentSessions = new Map() + private readonly disposers: (() => void)[] = [] + private shutdownTask: Promise> | undefined + private shuttingDown = false + + constructor( + private readonly ctx: Context, + private readonly transport: JsonRpcTransportPeer, + ) { + this.disposers.push(ctx.on('session/event', (session, event) => { + if (event.type === 'turn/end') { + const rec = this.sessions.get(String(session.id)) + if (rec) rec.lastTurnEnd = event.data.reason + } + this.transport.notify('session.event', { sessionId: String(session.id), event }) + })) + this.disposers.push(ctx.on('session/created', (session) => { + const parentSession = session.header.parentSession + if (parentSession === undefined) return + this.transport.notify('subagent.started', { + parentSessionId: String(parentSession), + childSessionId: String(session.id), + }) + })) + // Cache agent → session lineage on creation: by the time `subagent/end` + // fires the child agent may already be disposed and gone from the registry. + this.disposers.push(ctx.on('agent/created', (agent) => { + this.subagentSessions.set(String(agent.id), { + childSessionId: String(agent.session.id), + parentSessionId: agent.session.header.parentSession === undefined + ? undefined + : String(agent.session.header.parentSession), + }) + })) + this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { + const rec = this.subagentSessions.get(String(info.id)) + const agent = this.ctx.agents.get(info.id) + const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id)) + const parentSessionId = rec?.parentSessionId ?? ( + agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession) + ) + if (childSessionId === undefined) return + this.transport.notify('subagent.finished', { + provider: info.provider, + agentId: String(info.id), + ...(parentSessionId === undefined ? {} : { parentSessionId }), + childSessionId, + status: info.stopReason === 'completed' ? 'ok' : 'error', + stopReason: info.stopReason, + ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), + }) + })) + } + + /** + * Handle `initialize`: record the SDK deployment facts (cwd, model) and, when + * no registered adapter serves `params.model`, mount the DeepSeek adapter for + * it (credentials from `$DEEPSEEK_API_KEY`/`$DEEPSEEK_BASE_URL`) — a config + * that already registered an adapter for the model wins. + * @param params - the SDK handshake parameters. + * @returns the server identity for the handshake. + */ + async initialize(params: InitializeParams): Promise { + this.cwd = resolve(params.cwd) + this.model = params.model + if (!this.llmFiber && !this.hasAdapterFor(this.model)) { + this.llmFiber = await this.ctx.plugin(LlmDeepSeek, { models: [this.model] }) + } + return { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } } + } + + /** + * Handle `session/prompt`: get-or-create the session's agent, send the + * content as the user message, await turn settle (quiescence), then notify + * `session.finished` with the settled turn's outcome. A session accepts at + * most one prompt at a time; an overlapping request fails immediately while + * other sessions remain independent. + * @param params - the target session id and prompt content. + * @returns `{ accepted: true }` after the turn settled. + */ + async prompt(params: SessionPromptParams): Promise { + const rec = await this.getOrCreateSession(params.sessionId) + if (rec.activePrompt) throw new Error(`session already has an active prompt: ${params.sessionId}`) + rec.activePrompt = true + try { + rec.lastTurnEnd = undefined + rec.handle.agent.send(params.contentBlocks) + await rec.handle.agent.whenIdle() + const status = this.finishedStatus(rec.lastTurnEnd) + this.transport.notify('session.finished', { + sessionId: params.sessionId, + status, + reason: rec.lastTurnEnd, + }) + return { accepted: true } + } finally { + rec.activePrompt = false + } + } + + /** + * Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop + * quiescence), unmount the adapter fiber this server mounted (if any), and + * detach the event subscriptions. The CONTEXT stays up — the bin disposes it + * as part of process exit. + * @returns an empty object (the JSON-RPC result). + */ + shutdown(): Promise> { + this.shutdownTask ??= this.performShutdown() + return this.shutdownTask + } + + private async performShutdown(): Promise> { + this.shuttingDown = true + const pendingCreations = [...this.sessionCreations.values()] + await Promise.allSettled(pendingCreations) + this.sessionCreations.clear() + const records = [...this.sessions.values()] + this.sessions.clear() + this.subagentSessions.clear() + const failures: unknown[] = [] + while (this.disposers.length > 0) { + try { + this.disposers.pop()?.() + } catch (error) { + failures.push(error) + } + } + const teardownResults = await Promise.allSettled([ + ...records.map(rec => Promise.resolve().then(() => rec.handle.dispose())), + ...(this.llmFiber === undefined ? [] : [Promise.resolve().then(() => this.llmFiber?.dispose())]), + ]) + this.llmFiber = undefined + failures.push(...teardownResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'SDK server teardown failed') + return {} + } + + /** + * Dispatch one incoming JSON-RPC request to its typed handler. Throws (→ a + * JSON-RPC error response) on an unknown method. + * @param method - the JSON-RPC method name. + * @param params - the raw params object from the wire. + * @returns the handler's result, to be serialized as the response. + */ + async handleRequest(method: string, params: Record | undefined): Promise { + switch (method) { + case 'initialize': + return this.initialize(params as unknown as InitializeParams) + case 'session/prompt': + return this.prompt(params as unknown as SessionPromptParams) + case 'shutdown': + return this.shutdown() + default: + throw new Error(`unknown DeepSeek Harness SDK runtime method: ${method}`) + } + } + + private async getOrCreateSession(sessionId: string): Promise { + if (this.shuttingDown) throw new Error('SDK server is shutting down') + const existing = this.sessions.get(sessionId) + if (existing) return existing + const pending = this.sessionCreations.get(sessionId) + if (pending) return pending + const creation = this.createSession(sessionId) + this.sessionCreations.set(sessionId, creation) + void creation.then( + () => { this.sessionCreations.delete(sessionId) }, + () => { this.sessionCreations.delete(sessionId) }, + ) + return creation + } + + private async createSession(sessionId: string): Promise { + const handle = await this.ctx.agents.create({ + agentId: AgentId(sessionId), + sessionId: SessionId(sessionId), + meta: { cwd: this.cwd }, + agentOptions: { model: this.model }, + }) + const rec: SessionRecord = { handle, lastTurnEnd: undefined, activePrompt: false } + this.sessions.set(sessionId, rec) + return rec + } + + private finishedStatus(reason: TurnEndReason | undefined): 'ok' | 'error' { + if (!reason) return 'error' + return reason.kind === 'completed' ? 'ok' : 'error' + } + + private hasAdapterFor(model: string): boolean { + return this.ctx.get('llm')?.models().includes(model) ?? false + } +} diff --git a/packages/ui/jsonrpc/src/transport.ts b/packages/ui/jsonrpc/src/transport.ts new file mode 100644 index 0000000000..1f3011f2eb --- /dev/null +++ b/packages/ui/jsonrpc/src/transport.ts @@ -0,0 +1,238 @@ +/** + * Newline-delimited JSON-RPC 2.0 transport over a byte stream pair (the SDK + * server's stdio channel). One JSON frame per line; a frame with `id`+`method` + * is an incoming request, `id` alone matches a pending outgoing request, and + * `method` alone is a notification. Malformed lines are ignored (a resilient + * wire reader, not a validator); handler failures become JSON-RPC error + * responses, never a crashed transport. + * + * @module @deepseek-ai/dsh-jsonrpc/transport + */ + +import { randomUUID } from 'node:crypto' +import type { Readable, Writable } from 'node:stream' +import { StringDecoder } from 'node:string_decoder' + +type JsonRpcId = string | number +type RequestHandler = (method: string, params: Record) => Promise +type NotificationHandler = (method: string, params: Record) => void + +/** + * The outbound half of a JSON-RPC peer — what {@link HarnessSdkServer} needs + * to talk BACK to the host: awaited `request`s and fire-and-forget `notify`s. + * Narrow on purpose so tests substitute a recording fake without a stream pair. + */ +export interface JsonRpcTransportPeer { + /** + * Send a request to the remote peer and await its response. + * @param method - the JSON-RPC method name. + * @param params - the request parameters object. + * @returns the remote peer's `result`; rejects on a JSON-RPC `error` + * response, a write failure, or transport/input closure. + */ + request(method: string, params: Record): Promise + /** + * Send a notification (no response expected). An omitted `params` sends no + * `params` member at all. + * @param method - the JSON-RPC method name. + * @param params - the optional notification parameters object. + */ + notify(method: string, params?: Record): void +} + +interface PendingRequest { + resolve: (value: unknown) => void + reject: (error: Error) => void +} + +/** + * Line-delimited JSON-RPC 2.0 endpoint over a `Readable`/`Writable` pair. + * Inert until {@link start} attaches the input listeners; {@link close} + * detaches them and rejects every pending outgoing request (dispose-safe: the + * streams themselves are not destroyed — the caller owns them). Incoming + * requests are dispatched to the single {@link onRequest} handler (a missing + * handler answers `-32601 method not found`; a throwing handler answers + * `-32603` with the message); incoming notifications go to {@link + * onNotification} and are dropped without one. + */ +export class JsonRpcLineTransport implements JsonRpcTransportPeer { + private buffer = '' + private readonly decoder = new StringDecoder('utf8') + private started = false + private requestHandler: RequestHandler | undefined + private notificationHandler: NotificationHandler | undefined + private readonly pending = new Map() + + constructor( + private readonly input: Readable, + private readonly output: Writable, + ) {} + + /** Attach the input listeners and begin reading frames. Idempotent. */ + start(): void { + if (this.started) return + this.started = true + this.input.on('data', this.onData) + this.input.on('error', this.onInputError) + this.input.on('end', this.onInputEnd) + } + + /** + * Detach the input listeners and reject every pending outgoing request with + * "JSON-RPC transport closed". Safe to call without a prior {@link start}. + */ + close(): void { + this.input.off('data', this.onData) + this.input.off('error', this.onInputError) + this.input.off('end', this.onInputEnd) + this.failPending(new Error('JSON-RPC transport closed')) + } + + /** + * Install THE handler for incoming requests (a later call replaces it). + * @param handler - resolves to the response `result`; a rejection becomes a + * `-32603` error response carrying the message. + */ + onRequest(handler: RequestHandler): void { + this.requestHandler = handler + } + + /** + * Install THE handler for incoming notifications (a later call replaces it). + * @param handler - invoked per notification with the method and normalized + * params object. + */ + onNotification(handler: NotificationHandler): void { + this.notificationHandler = handler + } + + request(method: string, params: Record): Promise { + const id = `req_${randomUUID().replaceAll('-', '')}` + const message = { jsonrpc: '2.0', id, method, params } + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + try { + this.write(message) + } catch (error) { + this.pending.delete(id) + reject(error instanceof Error ? error : new Error(String(error))) + } + }) + } + + notify(method: string, params?: Record): void { + this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params }) + } + + /** + * Wait until every frame written before this call has reached the output's + * write callback. The empty queued write is a barrier and emits no protocol + * bytes. + * @returns a promise that settles with the output write callback. + */ + flush(): Promise { + return new Promise((resolve, reject) => { + this.output.write('', (error) => { + if (error) reject(error) + else resolve() + }) + }) + } + + private readonly onData = (chunk: Buffer | string): void => { + this.buffer += typeof chunk === 'string' ? chunk : this.decoder.write(chunk) + this.drainLines() + } + + private drainLines(): void { + for (;;) { + const newline = this.buffer.indexOf('\n') + if (newline < 0) break + const line = this.buffer.slice(0, newline).trim() + this.buffer = this.buffer.slice(newline + 1) + if (!line) continue + void this.handleLine(line) + } + } + + private readonly onInputError = (error: Error): void => { + this.failPending(error) + } + + private readonly onInputEnd = (): void => { + this.buffer += this.decoder.end() + this.drainLines() + this.failPending(new Error('JSON-RPC input closed')) + } + + private async handleLine(line: string): Promise { + let message: unknown + try { + message = JSON.parse(line) + } catch { + // Swallows ONLY JSON.parse syntax errors: a malformed wire line is a + // peer bug this resilient reader skips; nothing else runs in the try. + return + } + if (!message || typeof message !== 'object') return + const frame = message as Record + const id = frame.id + const method = frame.method + if ((typeof id === 'string' || typeof id === 'number') && typeof method === 'string') { + await this.handleIncomingRequest(id, method, objectParams(frame.params)) + return + } + if (typeof id === 'string' || typeof id === 'number') { + this.handleIncomingResponse(id, frame) + return + } + if (typeof method === 'string') { + this.notificationHandler?.(method, objectParams(frame.params)) + } + } + + private async handleIncomingRequest(id: JsonRpcId, method: string, params: Record): Promise { + const handler = this.requestHandler + if (!handler) { + this.writeError(id, -32601, `method not found: ${method}`) + return + } + try { + const result = await handler(method, params) + this.write({ jsonrpc: '2.0', id, result }) + } catch (error) { + this.writeError(id, -32603, error instanceof Error ? error.message : String(error)) + } + } + + private handleIncomingResponse(id: JsonRpcId, frame: Record): void { + const pending = this.pending.get(id) + if (!pending) return + this.pending.delete(id) + if (frame.error && typeof frame.error === 'object') { + const error = frame.error as Record + pending.reject(new Error(typeof error.message === 'string' ? error.message : 'JSON-RPC error')) + return + } + pending.resolve(frame.result) + } + + private writeError(id: JsonRpcId, code: number, message: string): void { + this.write({ jsonrpc: '2.0', id, error: { code, message } }) + } + + private write(message: Record): void { + this.output.write(`${JSON.stringify(message)}\n`) + } + + private failPending(error: Error): void { + const pending = [...this.pending.values()] + this.pending.clear() + for (const waiter of pending) waiter.reject(error) + } +} + +/** Normalize JSON-RPC `params` to a plain object (arrays and scalars collapse to `{}`). */ +function objectParams(params: unknown): Record { + return params && typeof params === 'object' && !Array.isArray(params) ? params as Record : {} +} diff --git a/packages/ui/jsonrpc/tests/plugin-apply.spec.ts b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts new file mode 100644 index 0000000000..8f4a504264 --- /dev/null +++ b/packages/ui/jsonrpc/tests/plugin-apply.spec.ts @@ -0,0 +1,316 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { PassThrough, Writable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as jsonrpc from '../src/index.ts' + +/** + * apply()-level lifecycle coverage for the @deepseek-ai/dsh-jsonrpc plugin: + * the plugin is mounted through the REAL namespace mount path — + * `ctx.plugin(jsonrpc, config)` over the module namespace object, exactly what + * the Loader hands cordis after `unwrapExports` (plugin-shape.spec pins that + * identity) — with the runtime-only `input`/`output`/`exit` seams from + * {@link jsonrpc.JsonRpcConfig} replacing the process stdio, so the whole + * pipeline (line transport → HarnessSdkServer → notifications back onto the + * wire) runs in-process. The scenarios pin the plugin's exit-lifecycle split: + * a `shutdown` REQUEST answers first, then disposes the plugin's own fiber and + * calls `exit(0)` exactly once (a racing second `shutdown` must not re-exit); + * a bare fiber dispose (HMR-style unload, no request) only stops serving and + * never touches `exit`. + */ + +/** One ordered observation on the plugin's outward-facing seams: a JSON-RPC frame written to `output`, or an `exit(code)` call. */ +type WireEvent = + | { kind: 'frame'; frame: Record } + | { kind: 'write-complete'; ids: (string | number)[] } + | { kind: 'exit'; code: number } + +interface ApplyHarness { + ctx: Context + /** The jsonrpc plugin's own fiber (NOT the root), for the HMR-style dispose scenario. */ + fiber: Awaited> + /** Every output frame and exit call, in observation order — ordering assertions read this. */ + events: WireEvent[] + outputErrors: Error[] + send(frame: Record): void + sendRaw(text: string): void + frames(): Record[] + exits(): number[] + waitForFrame(predicate: (frame: Record) => boolean, description: string): Promise> + dispose(): Promise +} + +/** Poll `get` until it yields a value (5s cap) — the output side is fed asynchronously from the transport's read loop. */ +async function waitFor(get: () => T | undefined, description: string): Promise { + const deadline = Date.now() + 5000 + for (;;) { + const value = get() + if (value !== undefined) return value + if (Date.now() > deadline) throw new Error(`timed out waiting for ${description}`) + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +/** Let pending microtasks, setImmediate callbacks, and stream events drain — for asserting that something did NOT happen. */ +async function settle(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +/** + * Boot a minimal harness context (agent-core bundle + JSONL persistence, the + * server.spec recipe) and mount the jsonrpc plugin on it through the real + * namespace mount path, with in-memory seams standing in for stdio/exit. + */ +async function mountPlugin( + storageDir: string, + options: { writeDelayMs?: number; failFlush?: boolean } = {}, +): Promise { + const ctx = new Context() + await ctx.plugin(agentCore) + await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) + await new Promise(resolve => setTimeout(resolve, 50)) + + const input = new PassThrough() + const events: WireEvent[] = [] + const outputErrors: Error[] = [] + let pendingOutput = '' + // A hand-rolled Writable (not a PassThrough): _write records frames on + // admission and write-complete only when its callback fires, so a delayed + // output proves exit waits for the transport's flush barrier. + const output = new Writable({ + write(chunk: Buffer, _encoding, callback) { + const ids: (string | number)[] = [] + pendingOutput += chunk.toString('utf8') + for (;;) { + const newline = pendingOutput.indexOf('\n') + if (newline < 0) break + const line = pendingOutput.slice(0, newline).trim() + pendingOutput = pendingOutput.slice(newline + 1) + if (line) { + const frame = JSON.parse(line) as Record + events.push({ kind: 'frame', frame }) + if (typeof frame.id === 'string' || typeof frame.id === 'number') ids.push(frame.id) + } + } + const complete = (): void => { + if (options.failFlush === true && chunk.length === 0) { + callback(new Error('flush callback failed')) + return + } + events.push({ kind: 'write-complete', ids }) + callback() + } + if ((options.writeDelayMs ?? 0) > 0) setTimeout(complete, options.writeDelayMs) + else complete() + }, + }) + output.on('error', (error: Error) => { outputErrors.push(error) }) + const exit = (code: number): void => { events.push({ kind: 'exit', code }) } + + const fiber = await ctx.plugin(jsonrpc, { input, output, exit }) + + const frames = (): Record[] => + events.flatMap(event => event.kind === 'frame' ? [event.frame] : []) + return { + ctx, + fiber, + events, + outputErrors, + send: (frame) => { input.write(`${JSON.stringify(frame)}\n`) }, + sendRaw: (text) => { input.write(text) }, + frames, + exits: () => events.flatMap(event => event.kind === 'exit' ? [event.code] : []), + waitForFrame: (predicate, description) => waitFor(() => frames().find(predicate), description), + dispose: async () => { await ctx.fiber.dispose() }, + } +} + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + vi.unstubAllEnvs() +}) + +/** The server.spec mock OpenAI-compatible SSE endpoint, so a prompt turn completes without a real key. */ +async function mockCompletionServer(): Promise<{ url: string; requests: unknown[] }> { + const requests: unknown[] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.write('data: [DONE]\n\n') + response.end() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, requests } +} + +describe('dsh-jsonrpc plugin apply', () => { + it('serves initialize over the injected stdio pair', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-init-')) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + const harness = await mountPlugin(storageDir) + try { + harness.send({ jsonrpc: '2.0', id: 'init-1', method: 'initialize', params: { cwd: storageDir, model: 'apply-model' } }) + + const response = await harness.waitForFrame(frame => frame.id === 'init-1', 'initialize response') + expect(response).toEqual({ + jsonrpc: '2.0', + id: 'init-1', + result: { serverInfo: { name: 'deepseek-harness-sdk-runtime', version: '0.0.1' } }, + }) + expect(harness.exits()).toEqual([]) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('drives a session/prompt turn end-to-end and forwards session notifications as output frames', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-prompt-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const harness = await mountPlugin(storageDir) + try { + harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, model: 'dsagent-model' } }) + await harness.waitForFrame(frame => frame.id === 1, 'initialize response') + + harness.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'fix it' }] }, + }) + const response = await harness.waitForFrame(frame => frame.id === 2, 'prompt response') + expect(response.result).toEqual({ accepted: true }) + + expect(llmServer.requests).toHaveLength(1) + const body = llmServer.requests[0] as { model: string; messages: { role: string }[] } + expect(body.model).toBe('dsagent-model') + expect(body.messages.at(-1)?.role).toBe('user') + + // The server's notify() path rides the SAME transport apply() built: + // session.event / session.finished arrive as id-less frames on output. + const notifications = harness.frames().filter(frame => frame.id === undefined) + expect(notifications.some(frame => frame.method === 'session.event')).toBe(true) + expect(notifications.find(frame => frame.method === 'session.finished')).toMatchObject({ + jsonrpc: '2.0', + params: { sessionId: 'main', status: 'ok' }, + }) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) + const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) + try { + // Two shutdown frames in ONE chunk: both are dispatched from the same + // read-loop pass, so both setImmediate exit callbacks get scheduled and + // the second must hit the `exiting` guard instead of re-entering. + const first = { jsonrpc: '2.0', id: 'sd-1', method: 'shutdown' } + const second = { jsonrpc: '2.0', id: 'sd-2', method: 'shutdown' } + harness.sendRaw(`${JSON.stringify(first)}\n${JSON.stringify(second)}\n`) + + await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit recorder call') + expect(harness.exits()).toEqual([0]) + + // Response-then-exit ordering: both response write callbacks and the + // empty flush barrier complete before exit(0), even on delayed output. + const exitIndex = harness.events.findIndex(event => event.kind === 'exit') + const firstResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-1') + const secondResponse = harness.events.findIndex(event => event.kind === 'frame' && event.frame.id === 'sd-2') + const firstComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-1')) + const secondComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.includes('sd-2')) + const flushComplete = harness.events.findIndex(event => event.kind === 'write-complete' && event.ids.length === 0) + expect(firstResponse).toBeGreaterThanOrEqual(0) + expect(secondResponse).toBeGreaterThanOrEqual(0) + expect(firstComplete).toBeGreaterThan(firstResponse) + expect(secondComplete).toBeGreaterThan(secondResponse) + expect(flushComplete).toBeGreaterThan(firstComplete) + expect(flushComplete).toBeGreaterThan(secondComplete) + expect(exitIndex).toBeGreaterThan(flushComplete) + + // Idempotent: the racing second shutdown never produces a second exit. + await settle() + expect(harness.exits()).toEqual([0]) + + // The plugin fiber is disposed: the transport reads no further frames. + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'after-exit', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('still disposes and exits once when the flush callback fails', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-flush-failure-')) + const harness = await mountPlugin(storageDir, { failFlush: true }) + try { + harness.send({ jsonrpc: '2.0', id: 'sd-fail', method: 'shutdown' }) + + await waitFor(() => harness.exits().length > 0 ? true : undefined, 'exit after flush failure') + await settle() + expect(harness.exits()).toEqual([0]) + expect(harness.outputErrors.map(error => error.message)).toEqual(['flush callback failed']) + + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'after-flush-failure', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('stops serving on a bare fiber dispose (HMR-style unload) without calling exit', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-dispose-')) + const harness = await mountPlugin(storageDir) + try { + // Prove the pipeline is live first (an unknown method still answers, as + // a JSON-RPC error frame — the transport's handler-rejection path). + harness.send({ jsonrpc: '2.0', id: 'probe-1', method: 'nope/unknown' }) + const error = await harness.waitForFrame(frame => frame.id === 'probe-1', 'error response for unknown method') + expect(error.error).toMatchObject({ + code: -32603, + message: 'unknown DeepSeek Harness SDK runtime method: nope/unknown', + }) + + await harness.fiber.dispose() + + // The effect disposer shut the server and closed the transport — later + // frames are never read — and the exit seam was never touched. + const before = harness.frames().length + harness.send({ jsonrpc: '2.0', id: 'probe-2', method: 'initialize', params: { cwd: storageDir, model: 'x' } }) + await settle() + expect(harness.frames().length).toBe(before) + expect(harness.exits()).toEqual([]) + } finally { + await harness.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/ui/jsonrpc/tests/plugin-shape.spec.ts b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..3edbf1514f --- /dev/null +++ b/packages/ui/jsonrpc/tests/plugin-shape.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as jsonrpc from '../src/index.ts' + +/** + * REAL-export-path guard for the @deepseek-ai/dsh-jsonrpc namespace plugin + * (the packages/AGENTS.md red line: a plugin shipped via `cordis.yml` needs a + * test through the real Loader/export path). A hand-built `ctx.plugin({...})` + * mount bypasses `unwrapExports` — the exact path that once collapsed a + * namespace plugin with a stray `export default` and silently dropped its + * `inject` (docs/postmortem/0001) — so this spec drives the REAL + * `Loader.unwrapExports` over the module namespace and asserts the + * `name`/`inject`/`Config`/`apply` shape survives it intact. + */ +describe('dsh-jsonrpc plugin export shape', () => { + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { + // A stray `export default` would make `unwrapExports` (`exports.default ?? + // exports`) collapse the module to the bare default, dropping `inject` — + // the plugin would then throw "cannot get property … without inject" at + // its first `ctx.agents` read. Adding `export default` fails this test. + expect('default' in jsonrpc).toBe(false) + expect(typeof jsonrpc.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(jsonrpc) as Record + expect(unwrapped).toBe(jsonrpc) + expect(unwrapped.name).toBe('jsonrpc') + expect(unwrapped.inject).toEqual(['agents']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts new file mode 100644 index 0000000000..1cde550f2e --- /dev/null +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -0,0 +1,572 @@ +import { createServer } from 'node:http' +import type { IncomingMessage, Server, ServerResponse } from 'node:http' +import { mkdtemp, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as agentCore from '@deepseek-ai/dsh-agent-core' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' + +class FakeTransport implements JsonRpcTransportPeer { + notifications: { method: string; params?: Record }[] = [] + + async request(method: string, params: Record): Promise { + throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`) + } + + notify(method: string, params?: Record): void { + this.notifications.push(params === undefined ? { method } : { method, params }) + } +} + +const servers: Server[] = [] + +afterEach(async () => { + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))) + vi.unstubAllEnvs() +}) + +async function mockCompletionServer(): Promise<{ url: string; requests: unknown[]; headers: IncomingMessage['headers'][] }> { + const requests: unknown[] = [] + const headers: IncomingMessage['headers'][] = [] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + request.on('end', () => { + requests.push(JSON.parse(body)) + headers.push(request.headers) + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.write('data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') + response.write('data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') + response.write('data: [DONE]\n\n') + response.end() + }) + }) + servers.push(server) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('no port') + return { url: `http://127.0.0.1:${address.port}`, requests, headers } +} + +async function makeHarness(storageDir: string) { + const ctx = new Context() + await ctx.plugin(agentCore) + await ctx.plugin(SubagentService) + await ctx.plugin(SessionPersistenceJsonl, { root: storageDir }) + await new Promise(resolve => setTimeout(resolve, 50)) + return ctx +} + +/** Drive the owning service so test lifecycle events carry the real parent scope. */ +async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise { + const disposeProvider = ctx.subagents.registerProvider({ + name: info.provider, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + async start() { + return { + id: info.id, + result: info.lastAssistantMessage === undefined + ? Promise.reject(new Error('synthetic infrastructure failure')) + : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }), + dispose: () => Promise.resolve(), + } + }, + }) + try { + const run = await ctx.subagents.start(info.provider, { + parent, + prompt: [], + signal: new AbortController().signal, + }) + await run.result.then(() => undefined, () => undefined) + await run.dispose() + } finally { + disposeProvider() + } +} + +describe('HarnessSdkServer', () => { + it('creates a harness agent and calls the configured OpenAI-compatible endpoint', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + const init = await server.handleRequest('initialize', { + cwd: storageDir, + model: 'dsagent-model', + }) as { serverInfo: { name: string } } + expect(init.serverInfo.name).toBe('deepseek-harness-sdk-runtime') + + await server.handleRequest('session/prompt', { + sessionId: 'main', + contentBlocks: [{ type: 'text', text: 'fix it' }], + }) + + expect(llmServer.requests).toHaveLength(1) + const body = llmServer.requests[0] as { model: string; messages: { role: string }[] } + expect(body.model).toBe('dsagent-model') + expect(body.messages[0]?.role).toBe('system') + expect(body.messages.at(-1)?.role).toBe('user') + expect(llmServer.headers[0]?.authorization).toBe('Bearer test-key') + expect(transport.notifications.some(n => n.method === 'session.event')).toBe(true) + expect(transport.notifications.at(-1)).toMatchObject({ + method: 'session.finished', + params: { sessionId: 'main', status: 'ok' }, + }) + + await server.handleRequest('session/prompt', { + sessionId: 'main', + contentBlocks: [{ type: 'text', text: 'again' }], + }) + expect(llmServer.requests).toHaveLength(2) + + const orphanHandle = await ctx.agents.create({ + agentId: AgentId('orphan-agent'), + sessionId: SessionId('orphan-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'dsagent-model' }, + }) + orphanHandle.agent.send([{ type: 'text', text: 'outside the sdk session map' }]) + await orphanHandle.agent.whenIdle() + await orphanHandle.dispose() + expect(llmServer.requests).toHaveLength(3) + + await server.handleRequest('shutdown', undefined) + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('rejects overlapping prompts for one session without serializing other sessions', async () => { + let releaseMain: (() => void) | undefined + const firstMainIdle = new Promise((resolve) => { releaseMain = resolve }) + const mainWhenIdle = vi.fn<() => Promise>() + .mockReturnValueOnce(firstMainIdle) + .mockResolvedValue(undefined) + const mainSend = vi.fn() + const mainAgent = { + send: mainSend, + whenIdle: mainWhenIdle, + } as unknown as Agent + const otherSend = vi.fn() + const otherAgent = { + send: otherSend, + whenIdle: vi.fn(() => Promise.resolve()), + } as unknown as Agent + const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } + const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } + const create = vi.fn(async (options: { agentId: AgentId }) => + String(options.agentId) === 'main' ? mainHandle : otherHandle) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) + const prompt = (sessionId: string, text: string) => server.prompt({ + sessionId, + contentBlocks: [{ type: 'text', text }], + }) + + const first = prompt('main', 'first') + await vi.waitFor(() => { expect(mainSend).toHaveBeenCalledOnce() }) + + await expect(prompt('main', 'overlap')).rejects.toThrow('session already has an active prompt: main') + await expect(prompt('other', 'independent')).resolves.toEqual({ accepted: true }) + releaseMain?.() + await expect(first).resolves.toEqual({ accepted: true }) + await expect(prompt('main', 'sequential')).resolves.toEqual({ accepted: true }) + + mainWhenIdle.mockRejectedValueOnce(new Error('turn wait failed')) + await expect(prompt('main', 'failing')).rejects.toThrow('turn wait failed') + await expect(prompt('main', 'after failure')).resolves.toEqual({ accepted: true }) + + expect(mainSend).toHaveBeenCalledTimes(4) + expect(otherSend).toHaveBeenCalledOnce() + await server.shutdown() + expect(mainHandle.dispose).toHaveBeenCalledOnce() + expect(otherHandle.dispose).toHaveBeenCalledOnce() + }) + + it('notifies the host when a child session is created with parent lineage', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + ctx.sessions.create(SessionId('root-session'), { + meta: { cwd: storageDir }, + }) + ctx.sessions.create(SessionId('child-session'), { + meta: { cwd: storageDir, parentSession: SessionId('main') }, + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.started', + params: { + parentSessionId: 'main', + childSessionId: 'child-session', + }, + }) + + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('creates an SDK session without an optional system prompt', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-no-system-')) + const llmServer = await mockCompletionServer() + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await server.initialize({ cwd: storageDir, model: 'plain-model' }) + await server.prompt({ + sessionId: 'plain', + contentBlocks: [{ type: 'text', text: 'hello' }], + }) + + expect(llmServer.requests).toHaveLength(1) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('notifies the host when a subagent run settles', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-end-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + const parentHandle = await ctx.agents.create({ + agentId: AgentId('parent-agent'), + sessionId: SessionId('main'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const handle = await ctx.agents.create({ + agentId: AgentId('child-agent'), + sessionId: SessionId('child-session'), + meta: { cwd: storageDir, parentSession: SessionId('main') }, + agentOptions: { model: 'deepseek' }, + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'spawn', + id: AgentId('child-agent'), + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'child done' }], + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'child-agent', + parentSessionId: 'main', + childSessionId: 'child-session', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'child done' }], + }, + }) + + await handle.dispose() + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('falls back to live agent lineage for uncached subagent end events', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) + const ctx = await makeHarness(storageDir) + let parentHandle: AgentHandle | undefined + let handle: AgentHandle | undefined + let failedHandle: AgentHandle | undefined + try { + parentHandle = await ctx.agents.create({ + agentId: AgentId('fallback-parent-agent'), + sessionId: SessionId('fallback-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + handle = await ctx.agents.create({ + agentId: AgentId('fallback-child-agent'), + sessionId: SessionId('fallback-child-session'), + meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, + agentOptions: { model: 'deepseek' }, + }) + failedHandle = await ctx.agents.create({ + agentId: AgentId('failed-child-agent'), + sessionId: SessionId('failed-child-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'fork', + id: AgentId('fallback-child-agent'), + stopReason: 'max-tokens', + lastAssistantMessage: [], + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'fork', + id: AgentId('failed-child-agent'), + stopReason: 'error', + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'fork', + id: AgentId('missing-child-agent'), + stopReason: 'error', + }) + + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'fork', + agentId: 'fallback-child-agent', + parentSessionId: 'fallback-parent', + childSessionId: 'fallback-child-session', + status: 'error', + stopReason: 'max-tokens', + lastAssistantMessage: [], + }, + }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'fork', + agentId: 'failed-child-agent', + childSessionId: 'failed-child-session', + status: 'error', + stopReason: 'error', + }, + }) + expect(transport.notifications.some(n => + n.method === 'subagent.finished' + && n.params?.agentId === 'missing-child-agent', + )).toBe(false) + + await server.shutdown() + } finally { + await handle?.dispose() + await failedHandle?.dispose() + await parentHandle?.dispose() + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('does not re-register an LLM adapter that already exists', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-existing-llm-')) + const ctx = await makeHarness(storageDir) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + await ctx.plugin(LlmDeepSeek, { models: ['preinstalled-model'] }) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + const inspect = server as unknown as { hasAdapterFor(model: string): boolean } + + expect(inspect.hasAdapterFor('preinstalled-model')).toBe(true) + expect(inspect.hasAdapterFor('missing-model')).toBe(false) + await server.initialize({ cwd: storageDir, model: 'preinstalled-model' }) + + expect(ctx.get('llm')?.models().filter(model => model === 'preinstalled-model')).toEqual(['preinstalled-model']) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('registers a missing model when an LLM service already exists', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-new-llm-')) + const ctx = await makeHarness(storageDir) + vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') + await ctx.plugin(LlmDeepSeek, { models: ['other-model'] }) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await server.initialize({ cwd: storageDir, model: 'new-model' }) + + expect(ctx.get('llm')?.models()).toEqual(expect.arrayContaining(['other-model', 'new-model'])) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('classifies defensive finish states', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-finish-states-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + finishedStatus(reason: unknown): 'ok' | 'error' + shutdown(): Promise> + } + + expect(server.finishedStatus(undefined)).toBe('error') + expect(server.finishedStatus({ kind: 'max-tokens' })).toBe('error') + expect(server.finishedStatus({ kind: 'error' })).toBe('error') + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('reports no adapter when the LLM service is absent', async () => { + const ctx = new Context() + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + hasAdapterFor(model: string): boolean + shutdown(): Promise> + } + + expect(server.hasAdapterFor('missing-model')).toBe(false) + await server.shutdown() + } finally { + await ctx.fiber.dispose() + } + }) + + it('rejects unknown JSON-RPC runtime methods', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-unknown-')) + const ctx = await makeHarness(storageDir) + try { + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await expect(server.handleRequest('does/not/exist', {})) + .rejects + .toThrow('unknown DeepSeek Harness SDK runtime method: does/not/exist') + + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('coalesces concurrent session creation and retries a failed creation', async () => { + let resolveShared: ((handle: AgentHandle) => void) | undefined + const sharedCreation = new Promise((resolve) => { resolveShared = resolve }) + const sharedHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) } + const retryHandle = { agent: {} as Agent, dispose: vi.fn(() => Promise.resolve()) } + const create = vi.fn<(options: unknown) => Promise>() + .mockReturnValueOnce(sharedCreation) + .mockRejectedValueOnce(new Error('creation failed')) + .mockResolvedValueOnce(retryHandle) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + getOrCreateSession(sessionId: string): Promise<{ handle: AgentHandle }> + shutdown(): Promise> + } + + const first = server.getOrCreateSession('shared') + const second = server.getOrCreateSession('shared') + expect(create).toHaveBeenCalledTimes(1) + resolveShared?.(sharedHandle) + const [firstRecord, secondRecord] = await Promise.all([first, second]) + expect(firstRecord).toBe(secondRecord) + + await expect(server.getOrCreateSession('retry')).rejects.toThrow('creation failed') + await expect(server.getOrCreateSession('retry')).resolves.toMatchObject({ handle: retryHandle }) + expect(create).toHaveBeenCalledTimes(3) + + await server.shutdown() + expect(sharedHandle.dispose).toHaveBeenCalledOnce() + expect(retryHandle.dispose).toHaveBeenCalledOnce() + await expect(server.getOrCreateSession('after-shutdown')).rejects.toThrow('SDK server is shutting down') + }) + + it('resolves a relative cwd before creating the session', async () => { + const create = vi.fn<(options: unknown) => Promise>() + .mockResolvedValue({ agent: {} as Agent, dispose: () => Promise.resolve() }) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create, get: () => undefined }, + get: () => ({ models: () => ['model'] }), + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + initialize(params: { cwd: string; model: string }): Promise + getOrCreateSession(sessionId: string): Promise + shutdown(): Promise> + } + + await server.initialize({ cwd: '.', model: 'model' }) + await server.getOrCreateSession('relative') + + expect(create).toHaveBeenCalledWith(expect.objectContaining({ meta: { cwd: process.cwd() } })) + await server.shutdown() + }) + + it('settles every teardown and aggregates multiple failures', async () => { + const firstDispose = vi.fn(() => { throw new Error('first teardown failed') }) + const secondDispose = vi.fn(() => Promise.reject(new Error('second teardown failed'))) + const ctx = { + on: vi.fn(() => () => undefined), + agents: { create: vi.fn(), get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) as unknown as { + sessions: Map + shutdown(): Promise> + } + server.sessions.set('first', { handle: { agent: {} as Agent, dispose: firstDispose }, lastTurnEnd: undefined, activePrompt: false }) + server.sessions.set('second', { handle: { agent: {} as Agent, dispose: secondDispose }, lastTurnEnd: undefined, activePrompt: false }) + + await expect(server.shutdown()).rejects.toThrow('SDK server teardown failed') + expect(firstDispose).toHaveBeenCalledOnce() + expect(secondDispose).toHaveBeenCalledOnce() + }) + + it('continues teardown after a subscription disposer fails', async () => { + let subscription = 0 + const listenerFailure = new Error('listener teardown failed') + const on = vi.fn(() => { + subscription += 1 + return subscription === 1 ? () => { throw listenerFailure } : () => undefined + }) + const ctx = { + on, + agents: { create: vi.fn(), get: () => undefined }, + get: () => undefined, + } as unknown as Context + const server = new HarnessSdkServer(ctx, new FakeTransport()) + + await expect(server.shutdown()).rejects.toBe(listenerFailure) + expect(on).toHaveBeenCalledTimes(4) + }) +}) diff --git a/packages/ui/jsonrpc/tests/transport.spec.ts b/packages/ui/jsonrpc/tests/transport.spec.ts new file mode 100644 index 0000000000..2f3173c7c9 --- /dev/null +++ b/packages/ui/jsonrpc/tests/transport.spec.ts @@ -0,0 +1,260 @@ +import { once } from 'node:events' +import { PassThrough, Writable } from 'node:stream' +import { describe, expect, it } from 'vitest' +import { JsonRpcLineTransport } from '../src/index.ts' + +function transportPair() { + const aToB = new PassThrough() + const bToA = new PassThrough() + const a = new JsonRpcLineTransport(bToA, aToB) + const b = new JsonRpcLineTransport(aToB, bToA) + return { a, b, aToB, bToA } +} + +describe('JsonRpcLineTransport', () => { + it('supports bidirectional requests and notifications over newline-delimited JSON-RPC', async () => { + const { a, b } = transportPair() + const notifications: Record[] = [] + + a.onRequest(async (method, params) => { + expect(method).toBe('echo') + return { echoed: params } + }) + b.onNotification((method, params) => { + notifications.push({ method, params }) + }) + a.start() + b.start() + + const response = await b.request('echo', { value: 42 }) + expect(response).toEqual({ echoed: { value: 42 } }) + + a.notify('session.finished', { sessionId: 'main', status: 'ok' }) + a.notify('heartbeat') + await new Promise(resolve => setTimeout(resolve, 10)) + expect(notifications).toEqual([ + { method: 'session.finished', params: { sessionId: 'main', status: 'ok' } }, + { method: 'heartbeat', params: {} }, + ]) + + a.close() + b.close() + }) + + it('reports JSON-RPC request errors from the remote peer', async () => { + const { a, b } = transportPair() + a.onRequest(async () => { + throw new Error('handler boom') + }) + a.start() + b.start() + + await expect(b.request('explode', {})).rejects.toThrow('handler boom') + + a.close() + b.close() + }) + + it('stringifies non-Error request handler failures', async () => { + const { a, b } = transportPair() + a.onRequest(async () => { + throw 'string boom' + }) + a.start() + b.start() + + await expect(b.request('explode-string', {})).rejects.toThrow('string boom') + + a.close() + b.close() + }) + + it('reports method-not-found when no request handler is installed', async () => { + const { a, b } = transportPair() + a.start() + b.start() + + await expect(b.request('missing', {})).rejects.toThrow('method not found: missing') + + a.close() + b.close() + }) + + it('normalizes non-object request params and ignores notifications without a handler', async () => { + const { aToB, bToA, b } = transportPair() + const seen: Record[] = [] + b.onRequest(async (method, params) => { + seen.push({ method, params }) + return { ok: true } + }) + b.start() + + aToB.write('{"jsonrpc":"2.0","method":"ignored"}\n') + aToB.write('{"jsonrpc":"2.0","id":7,"method":"array-params","params":[]}\n') + const chunk = (await once(bToA, 'data'))[0] as Buffer | string + + expect(seen).toEqual([{ method: 'array-params', params: {} }]) + expect(JSON.parse(String(chunk))).toEqual({ jsonrpc: '2.0', id: 7, result: { ok: true } }) + b.close() + }) + + it('ignores malformed frames and accepts notifications without params', async () => { + const { aToB, b } = transportPair() + const notifications: Record[] = [] + b.onNotification((method, params) => { + notifications.push({ method, params }) + }) + b.start() + b.start() + + aToB.write('not json\n') + aToB.write('\n') + aToB.write('null\n') + aToB.write('{"jsonrpc":"2.0","params":{}}\n') + aToB.write('{"jsonrpc":"2.0","method":"tick"}\n') + aToB.emit('data', '{"jsonrpc":"2.0","method":"string-chunk"}\n') + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(notifications).toEqual([ + { method: 'tick', params: {} }, + { method: 'string-chunk', params: {} }, + ]) + b.close() + }) + + it('preserves multibyte UTF-8 characters split across Buffer chunks', async () => { + const input = new PassThrough() + const output = new PassThrough() + const transport = new JsonRpcLineTransport(input, output) + const notifications: Record[] = [] + transport.onNotification((method, params) => { notifications.push({ method, params }) }) + transport.start() + + const frame = Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', method: 'message', params: { text: '你好' } })}\n`) + const character = Buffer.from('你') + const characterStart = frame.indexOf(character) + expect(characterStart).toBeGreaterThanOrEqual(0) + input.write(frame.subarray(0, characterStart + 1)) + input.write(frame.subarray(characterStart + 1)) + await new Promise(resolve => setTimeout(resolve, 10)) + + expect(notifications).toEqual([{ method: 'message', params: { text: '你好' } }]) + transport.close() + }) + + it('flush waits for all earlier output writes', async () => { + const events: string[] = [] + const output = new Writable({ + write(chunk: Buffer, _encoding, callback) { + const label = chunk.length === 0 ? 'barrier' : 'frame' + events.push(`start:${label}`) + setTimeout(() => { + events.push(`finish:${label}`) + callback() + }, 5) + }, + }) + const transport = new JsonRpcLineTransport(new PassThrough(), output) + + transport.notify('tick') + await transport.flush() + + expect(events).toEqual([ + 'start:frame', + 'finish:frame', + 'start:barrier', + 'finish:barrier', + ]) + transport.close() + }) + + it('reports an output callback failure from flush', async () => { + const output = { + write(_chunk: string, callback?: (error?: Error) => void) { + callback?.(new Error('flush failed')) + return true + }, + } + const transport = new JsonRpcLineTransport(new PassThrough(), output as never) + + await expect(transport.flush()).rejects.toThrow('flush failed') + }) + + it('rejects pending requests when the input closes', async () => { + const { aToB, b } = transportPair() + b.start() + + const pending = b.request('never-replies', {}) + aToB.end() + + await expect(pending).rejects.toThrow('JSON-RPC input closed') + b.close() + }) + + it('rejects pending requests when the input errors', async () => { + const { aToB, b } = transportPair() + b.start() + + const pending = b.request('never-replies', {}) + aToB.emit('error', new Error('input broke')) + + await expect(pending).rejects.toThrow('input broke') + b.close() + }) + + it('rejects pending requests when the transport closes', async () => { + const { b } = transportPair() + + const pending = b.request('never-replies', {}) + b.close() + + await expect(pending).rejects.toThrow('JSON-RPC transport closed') + }) + + it('rejects a request when writing the frame throws', async () => { + const input = new PassThrough() + const output = { + write() { + throw new Error('write exploded') + }, + } + const transport = new JsonRpcLineTransport(input, output as never) + + await expect(transport.request('write-fails', {})).rejects.toThrow('write exploded') + }) + + it('stringifies non-Error write failures', async () => { + const input = new PassThrough() + const output = { + write() { + throw 'write string' + }, + } + const transport = new JsonRpcLineTransport(input, output as never) + + await expect(transport.request('write-fails', {})).rejects.toThrow('write string') + }) + + it('uses a fallback message for malformed JSON-RPC error responses', async () => { + const { aToB, bToA, b } = transportPair() + b.start() + + const pending = b.request('remote-error', {}) + const requestChunk = (await once(bToA, 'data'))[0] as Buffer | string + const request = JSON.parse(String(requestChunk)) as { id: string } + aToB.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, error: {} })}\n`) + + await expect(pending).rejects.toThrow('JSON-RPC error') + b.close() + }) + + it('ignores responses that do not match a pending request', async () => { + const { aToB, b } = transportPair() + b.start() + + aToB.write('{"jsonrpc":"2.0","id":"unknown","result":{"ignored":true}}\n') + await new Promise(resolve => setTimeout(resolve, 10)) + + b.close() + }) +}) diff --git a/packages/ui/jsonrpc/tsconfig.json b/packages/ui/jsonrpc/tsconfig.json new file mode 100644 index 0000000000..dcd57ef9af --- /dev/null +++ b/packages/ui/jsonrpc/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/llm-deepseek" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../subagent/subagent" + } + ] +} diff --git a/packages/workflow/workflow-workerthread/README.md b/packages/workflow/workflow-workerthread/README.md index b9786ae1e2..023b562b42 100644 --- a/packages/workflow/workflow-workerthread/README.md +++ b/packages/workflow/workflow-workerthread/README.md @@ -32,7 +32,7 @@ Unknown options, malformed arguments, unsupported schemas, tripped caps, provide ## Run sequence -`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. +`start()` validates meta and parses the body, creates the worker, and returns a holder-owned `WorkflowRun`. Source mode uses a data-URL bootstrap that installs the TypeScript transforms inside the worker; built mode passes the sibling CommonJS bundle `lib/worker.cjs` as a filesystem string. CommonJS is required because pkg's VFS Worker hook compiles filesystem-string entries in that format; the same entry also works under ordinary Node resolution. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each `agent()` call: diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index ed934cd0cc..afaf840f78 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -13,14 +13,14 @@ }, "./worker": { "types": "./lib/types/worker.d.ts", - "default": "./lib/worker.js" + "default": "./lib/worker.cjs" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", - "lib/worker.js", + "lib/worker.cjs", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src" diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 95c07f0d2d..9ce754c0c6 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -43,6 +43,7 @@ import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' +import { fileURLToPath } from 'node:url' import type { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' @@ -87,12 +88,12 @@ interface ChildRecord { * AMBIENT channel only — an escapee still holds process-wide privileges * like fs access (the README's trust premise stands). * @param init - the run payload, passed as `workerData`. - * @returns the entry URL and the Worker options to spawn it with. + * @returns the entry path or URL and the Worker options to spawn it with. */ -function resolveWorkerSpawn(init: WorkerInit): { entry: URL; options: WorkerOptions } { +function resolveWorkerSpawn(init: WorkerInit): { entry: string | URL; options: WorkerOptions } { /* v8 ignore next 3 -- the built-output arm: tests always run unbuilt (src/); the built-worker e2e exercises this shape for real */ if (!import.meta.url.endsWith('.ts')) { - return { entry: new URL('./worker.js', import.meta.url), options: { workerData: init, env: {}, execArgv: [] } } + return { entry: fileURLToPath(new URL('./worker.cjs', import.meta.url)), options: { workerData: init, env: {}, execArgv: [] } } } // Resolve tsx lazily: only the unbuilt shape executes this arm, so a built // consumer never needs the dev-only loader installed. A JavaScript entry is diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index 545be831f6..779dd50600 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -8,17 +8,17 @@ import { describe, expect, it } from 'vitest' const packageRoot = fileURLToPath(new URL('..', import.meta.url)) const builtIndex = join(packageRoot, 'lib', 'index.js') -const builtWorker = join(packageRoot, 'lib', 'worker.js') +const builtWorker = join(packageRoot, 'lib', 'worker.cjs') const run = promisify(execFile) /** * The BUILT-output guard for the worker entry: every other suite runs * unbuilt (src/ + tsx), so nothing else proves that `lib/index.js` resolves - * its sibling `lib/worker.js` and that the bundle boots a worker under plain + * its sibling `lib/worker.cjs` and that the bundle boots a worker under plain * node (no tsx loader). Keyless — a zero-agent script needs no provider — * and self-skips until `pnpm run build` has produced the bundles. */ -describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.js)', () => { +describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built worker entry (lib/worker.cjs)', () => { it('the built engine spawns its built worker under plain node and completes a run', async () => { // ESM resolves bare specifiers from the IMPORTING FILE's location, so the // driver must live inside the package for its node_modules to apply — a diff --git a/packages/workflow/workflow-workerthread/tsdown.config.ts b/packages/workflow/workflow-workerthread/tsdown.config.ts index 3102a36c1c..c163fe4369 100644 --- a/packages/workflow/workflow-workerthread/tsdown.config.ts +++ b/packages/workflow/workflow-workerthread/tsdown.config.ts @@ -6,7 +6,9 @@ import { defineConfig } from 'tsdown' * entries are JS emitted by tsc under lib/types and are bundled as two * single-entry passes so shared modules (realm, runtime, session) are inlined * into each instead of split into a hash-named chunk (the worker entry must - * be a self-contained file the Worker constructor can load by path). + * be a self-contained file the Worker constructor can load by path). The + * worker bundle is CommonJS because pkg's VFS Worker hook compiles + * filesystem-string entries as CommonJS. */ export default defineConfig([ { @@ -22,7 +24,7 @@ export default defineConfig([ { entry: ['lib/types/worker.js'], outDir: 'lib', - format: ['esm'], + format: ['cjs'], platform: 'node', target: 'es2024', fixedExtension: false, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 649a986385..d02b4a9ca6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,9 +29,15 @@ importers: eslint: specifier: ^10.4.1 version: 10.5.0(jiti@2.7.0) + eslint-plugin-sonarjs: + specifier: ^4.1.0 + version: 4.1.0(eslint@10.5.0(jiti@2.7.0)) fast-check: specifier: ^4.8.0 version: 4.8.0 + jscpd: + specifier: ^5.0.12 + version: 5.0.12 jsdom: specifier: 29.1.1 version: 29.1.1 @@ -1252,6 +1258,50 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/jsonrpc: + dependencies: + schemastery: + specifier: ^3.17.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../core/agent-core + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + + packages/ui/jsonrpc-agent: + dependencies: + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot + devDependencies: + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': @@ -1576,6 +1626,204 @@ importers: specifier: ^4.19.2 version: 4.22.4 + python/sdk-runtime: + dependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../vendor/loader + '@cordisjs/plugin-timer': + specifier: workspace:^ + version: link:../../vendor/timer + '@deepseek-ai/dsh-acp': + specifier: workspace:^ + version: link:../../packages/ui/acp + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../packages/core/agent + '@deepseek-ai/dsh-agent-core': + specifier: workspace:^ + version: link:../../packages/core/agent-core + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../packages/core/agent-loop + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../packages/bash/bash + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../packages/bash/bash-local + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../packages/util/brand + '@deepseek-ai/dsh-code-runtime': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime + '@deepseek-ai/dsh-code-runtime-worker': + specifier: workspace:^ + version: link:../../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../packages/compact/compact + '@deepseek-ai/dsh-compact-basic': + specifier: workspace:^ + version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../packages/fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../packages/fs/fs-local + '@deepseek-ai/dsh-fs-policy': + specifier: workspace:^ + version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-hook-protocol': + specifier: workspace:^ + version: link:../../packages/hooks/hook-protocol + '@deepseek-ai/dsh-hooks-claude': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-claude + '@deepseek-ai/dsh-hooks-codex': + specifier: workspace:^ + version: link:../../packages/hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../packages/support/invariants + '@deepseek-ai/dsh-jsonrpc': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc + '@deepseek-ai/dsh-jsonrpc-agent': + specifier: workspace:^ + version: link:../../packages/ui/jsonrpc-agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../packages/llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../packages/llm/llm-deepseek + '@deepseek-ai/dsh-llm-pi-ai': + specifier: workspace:^ + version: link:../../packages/llm/llm-pi-ai + '@deepseek-ai/dsh-repeat-tool-guard': + specifier: workspace:^ + version: link:../../packages/guard/repeat-tool-guard + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../packages/core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../packages/core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-jsonl + '@deepseek-ai/dsh-session-persistence-sqlite': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-persistence-sqlite + '@deepseek-ai/dsh-skill': + specifier: workspace:^ + version: link:../../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:^ + version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/subagent + '@deepseek-ai/dsh-subagent-acp': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-acp + '@deepseek-ai/dsh-subagent-fork': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-fork + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-spawn + '@deepseek-ai/dsh-subagent-subprocess': + specifier: workspace:^ + version: link:../../packages/subagent/subagent-subprocess + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../packages/core/system-prompt + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../packages/util/timeout + '@deepseek-ai/dsh-timeout-policy': + specifier: workspace:^ + version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../packages/bash/tool-bash + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../packages/cordis/tool-cordis + '@deepseek-ai/dsh-tool-fs': + specifier: workspace:^ + version: link:../../packages/fs/tool-fs + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:^ + version: link:../../packages/skill/tool-skill + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-todo': + specifier: workspace:^ + version: link:../../packages/todo/tool-todo + '@deepseek-ai/dsh-tool-web': + specifier: workspace:^ + version: link:../../packages/web/tool-web + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/tool-workflow + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../packages/core/tools + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../../packages/ui/user-interaction + '@deepseek-ai/dsh-web': + specifier: workspace:^ + version: link:../../packages/web/web + '@deepseek-ai/dsh-web-fetch-local': + specifier: workspace:^ + version: link:../../packages/web/web-fetch-local + '@deepseek-ai/dsh-web-search-deepseek': + specifier: workspace:^ + version: link:../../packages/web/web-search-deepseek + '@deepseek-ai/dsh-web-search-exa': + specifier: workspace:^ + version: link:../../packages/web/web-search-exa + '@deepseek-ai/dsh-web-search-perplexity': + specifier: workspace:^ + version: link:../../packages/web/web-search-perplexity + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../packages/workflow/workflow + '@deepseek-ai/dsh-workflow-workerthread': + specifier: workspace:^ + version: link:../../packages/workflow/workflow-workerthread + cordis: + specifier: workspace:^ + version: link:../../vendor/cordis + vendor/cordis: dependencies: '@cordisjs/plugin-include': @@ -3050,6 +3298,14 @@ packages: buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + builtin-modules@3.3.0: + resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} + engines: {node: '>=6'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + cac@7.0.0: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} @@ -3356,6 +3612,11 @@ packages: resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} engines: {node: '>=12'} + eslint-plugin-sonarjs@4.1.0: + resolution: {integrity: sha512-rh+FlVz0yfd2RNIb6WqSkuGh0addX/Qi5scwQ5FphXDFrM6fZKcxP1+attJ78yUKcyYfiu6MTaISPpAFPzqRJw==} + peerDependencies: + eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-scope@9.1.2: resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} @@ -3481,6 +3742,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + gaxios@7.1.5: resolution: {integrity: sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg==} engines: {node: '>=18'} @@ -3500,6 +3764,10 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + globals@17.7.0: + resolution: {integrity: sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==} + engines: {node: '>=18'} + globrex@0.1.2: resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} @@ -3606,6 +3874,44 @@ packages: resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} hasBin: true + jscpd-darwin-arm64@5.0.12: + resolution: {integrity: sha512-CQCDYhQY9yOGGeauzkRGYW/QtXurPCjfDkEhUoM/M5UdmpzxfG3i9mnNsaheJ0RdFRf73mL7JaLVRP8U2l5/kA==} + cpu: [arm64] + os: [darwin] + + jscpd-darwin-x64@5.0.12: + resolution: {integrity: sha512-7XNCDfChP9fPkIWLepd0zGnTov7/5mR7rJ5Utc1MdWFdgkN//qz6FAJ1FhQA0E/qjFedy9XTucfUCgLybSBsjw==} + cpu: [x64] + os: [darwin] + + jscpd-linux-arm64-gnu@5.0.12: + resolution: {integrity: sha512-i/usHD0/8twzb2Qo4vFWcnuAD4IDLS6K/mTXwYy1CiJZDw4gmjfH45jtjdKUyoutTAiNs+ZWZgx1gIRljUbFuA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + jscpd-linux-x64-gnu@5.0.12: + resolution: {integrity: sha512-TqaeSTaGawcqyXSrQvYJFtLRn4aQeHgphGGsq2ZtVAg+lPeuMmh81c3bl0yi2dL3gDX1RGBQKBVul1XQknOuoA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + jscpd-linux-x64-musl@5.0.12: + resolution: {integrity: sha512-WelugY1/rdoo0Y0sqJ2XQOIvyIZTfRe+vP3MJe4XvEUwPF0v+9SQoS34FnfblRhsddVixyNkgl7x/sHid9UMJw==} + cpu: [x64] + os: [linux] + libc: [musl] + + jscpd-windows-x64-msvc@5.0.12: + resolution: {integrity: sha512-01x1klKjvfzI6Of5tI9u72x7TIQZQLLG6kMdsEPJKl/BXbskdknrfCepeTXRBbdFPKs25jfcd0klZ4OdDvww5w==} + cpu: [x64] + os: [win32] + + jscpd@5.0.12: + resolution: {integrity: sha512-87dC+akj2mCywlt8p3xnRlDg0B55u4GDbfE9cuwOOeIxbEuWuIoNqGoYi65A0516aJ4EzAjGwBj1Qb/Ahc8WAQ==} + engines: {node: '>=18'} + hasBin: true + jsdom@29.1.1: resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} @@ -3636,6 +3942,10 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + jsx-ast-utils-x@0.1.0: + resolution: {integrity: sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -3802,6 +4112,9 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} @@ -4107,6 +4420,14 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + refa@0.12.1: + resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + regexp-ast-analysis@0.7.1: + resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -4173,6 +4494,10 @@ packages: schemastery@3.18.0: resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} + scslre@0.3.0: + resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} + engines: {node: ^14.0.0 || >=16.0.0} + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -4869,6 +5194,14 @@ snapshots: cosmokit: 1.8.1 js-yaml: 4.2.0 + '@cordisjs/plugin-include@1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6)': + dependencies: + '@cordisjs/plugin-loader': link:vendor/loader + cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + cosmokit: 1.8.1 + js-yaml: 4.2.0 + optional: true + '@cordisjs/plugin-loader@1.0.0-rc.4(cordis@4.0.0-rc.6)': dependencies: cordis: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) @@ -5855,6 +6188,10 @@ snapshots: buffer-equal-constant-time@1.0.1: {} + builtin-modules@3.3.0: {} + + bytes@3.1.2: {} + cac@7.0.0: {} ccount@2.0.1: {} @@ -5881,6 +6218,14 @@ snapshots: '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@1.0.0-rc.4)(cordis@4.0.0-rc.6) '@cordisjs/plugin-loader': 1.0.0-rc.4(cordis@4.0.0-rc.6) + cordis@4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader): + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-include': 1.0.4(@cordisjs/plugin-loader@vendor+loader)(cordis@4.0.0-rc.6) + '@cordisjs/plugin-loader': link:vendor/loader + cordis@4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader): dependencies: '@standard-schema/spec': 1.1.0 @@ -6186,6 +6531,23 @@ snapshots: escape-string-regexp@5.0.0: {} + eslint-plugin-sonarjs@4.1.0(eslint@10.5.0(jiti@2.7.0)): + dependencies: + '@eslint-community/regexpp': 4.12.2 + builtin-modules: 3.3.0 + bytes: 3.1.2 + eslint: 10.5.0(jiti@2.7.0) + functional-red-black-tree: 1.0.1 + globals: 17.7.0 + jsx-ast-utils-x: 0.1.0 + lodash.merge: 4.6.2 + minimatch: 10.2.5 + scslre: 0.3.0 + semver: 7.8.4 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + yaml: 2.9.0 + eslint-scope@9.1.2: dependencies: '@types/esrecurse': 4.3.1 @@ -6330,6 +6692,8 @@ snapshots: fsevents@2.3.3: optional: true + functional-red-black-tree@1.0.1: {} + gaxios@7.1.5: dependencies: extend: 3.0.2 @@ -6358,6 +6722,8 @@ snapshots: dependencies: is-glob: 4.0.3 + globals@17.7.0: {} + globrex@0.1.2: {} google-auth-library@10.7.0: @@ -6452,6 +6818,33 @@ snapshots: dependencies: argparse: 2.0.1 + jscpd-darwin-arm64@5.0.12: + optional: true + + jscpd-darwin-x64@5.0.12: + optional: true + + jscpd-linux-arm64-gnu@5.0.12: + optional: true + + jscpd-linux-x64-gnu@5.0.12: + optional: true + + jscpd-linux-x64-musl@5.0.12: + optional: true + + jscpd-windows-x64-msvc@5.0.12: + optional: true + + jscpd@5.0.12: + optionalDependencies: + jscpd-darwin-arm64: 5.0.12 + jscpd-darwin-x64: 5.0.12 + jscpd-linux-arm64-gnu: 5.0.12 + jscpd-linux-x64-gnu: 5.0.12 + jscpd-linux-x64-musl: 5.0.12 + jscpd-windows-x64-msvc: 5.0.12 + jsdom@29.1.1: dependencies: '@asamuzakjp/css-color': 5.1.11 @@ -6495,6 +6888,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + jsx-ast-utils-x@0.1.0: {} + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -6639,6 +7034,8 @@ snapshots: lodash-es@4.18.1: {} + lodash.merge@4.6.2: {} + long@5.3.2: {} longest-streak@3.1.0: {} @@ -7155,6 +7552,15 @@ snapshots: readdirp@4.1.2: {} + refa@0.12.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + + regexp-ast-analysis@0.7.1: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + require-from-string@2.0.2: {} resolve-pkg-maps@1.0.0: {} @@ -7247,6 +7653,12 @@ snapshots: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 + scslre@0.3.0: + dependencies: + '@eslint-community/regexpp': 4.12.2 + refa: 0.12.1 + regexp-ast-analysis: 0.7.1 + semver@7.8.4: {} shebang-command@2.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 6407a99f52..6dc85a6079 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,9 @@ packages: - vendor/* - packages/*/* + # Deploy root of the single-exe build: a pure dependency manifest whose + # closure is what the exe bundles and what the Python runtime distributes. + - python/sdk-runtime peerDependencyRules: allowedVersions: diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000000..696fcc2bf3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# Root pytest config: pin collection to the Python SDK's real test tree. +# Without this, a bare `pytest` from the repo root recursively collects the +# whole worktree — including gitignored residue such as scratch checkouts or +# venvs — and same-basename test modules collide with an "import file +# mismatch" collection error. +[pytest] +testpaths = python/sdk/tests +norecursedirs = node_modules .git dist-exe diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml new file mode 100644 index 0000000000..64e10faf58 --- /dev/null +++ b/python/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 35ed645ce0e4d5e4c88c8aae2fe33d94aea79b3e +README.zh.md: 214c5cd1900e52f9fe479247f9940a97bb22766b diff --git a/python/README.md b/python/README.md new file mode 100644 index 0000000000..35ed645ce0 --- /dev/null +++ b/python/README.md @@ -0,0 +1,76 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python packages for driving DeepSeek Harness as a subprocess: a client SDK that spawns the `dsh-jsonrpc-agent` binary and talks newline-delimited JSON-RPC over stdio. The runtime carrier is the single-file executable produced by this repo; design, build, and acceptance details live in [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + +## Packages + +| Directory | Dist / module | Role | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | Client SDK: the `DeepSeekHarness` high-level turns API and the lower-level `HarnessClient` JSON-RPC client | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Runtime carrier: locates the bundled runtime binaries and ships the default agent configuration | + +## Building the runtime executable + +The platform executables are build artifacts, not checked into git. From the repo root: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +Products land in `dist-exe/` and are synced into this package at `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`) — after a local build the SDK finds the executable with no further setup. The `build-exe-for-python-sdk` CI workflow (manual dispatch, or the `build-exe` PR label) exercises the same binaries but retains only the four release wheels. Which plugins the exe bundles and how the carriers are organized: [sdk-runtime README](sdk-runtime/README.md); the build also refreshes the dev-only node carrier (see "against the Node source" below). + +## Validating the SDK against the executable + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +For an interactive check (needs `DEEPSEEK_API_KEY` in the environment or the repo-root `.env`): + +```python +from deepseek_harness import DeepSeekHarness +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## Running the SDK against the Node source (no executable) + +Two flavors, both for repo members: + +- **Built node carrier** — set `DSH_RUNTIME_MODE=node` and the SDK runs `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on the system Node (>= 22.19). The tree is refreshed on every build-script run and is the same dependency closure the exe snapshots, so plugin semantics are identical. Never auto-selected, never distributed. +- **Unbuilt source (tsx)** — point the client straight at the bin's TypeScript source for edit-run loops and debugging: `launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")` with `cwd` at the repo root, plus a config via `cordis=...` (or rely on the default-config injection). [sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) is the worked example. + +## Distributing the Python packages + +The root [`package.json`](../package.json) version is authoritative for both Python distributions. The common staging script reads that version, injects it into both wheels, and pins the SDK metadata to the same `deepseek-harness-runtime-bin==X.Y.Z`; an optional `python-vX.Y.Z` release tag is accepted only when it matches the repository version. Build the pure SDK wheel once and one runtime wheel on each native platform: + +```sh +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --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=="$version" +``` + +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 matching `python-vX.Y.Z` 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 new file mode 100644 index 0000000000..214c5cd190 --- /dev/null +++ b/python/README.zh.md @@ -0,0 +1,76 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +以子进程方式驱动 DeepSeek Harness 的 Python 包:客户端 SDK spawn `dsh-jsonrpc-agent` 二进制,并通过 stdio 上按行分隔的 JSON-RPC 与之通信。运行时载体是本仓库产出的单文件可执行文件;设计、构建与验收细节见 [docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md](../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)。 + +## 包 + +| 目录 | 分发名 / 模块 | 职责 | +|---|---|---| +| [sdk](sdk/) | `deepseek-harness` / `deepseek_harness` | 客户端 SDK:高层回合 API `DeepSeekHarness` 与低层 JSON-RPC 客户端 `HarnessClient` | +| [sdk-runtime](sdk-runtime/) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 运行时载体:定位内置的运行时二进制,并携带默认的 agent(智能体)配置 | + +## 构建运行时可执行文件 + +各平台可执行文件是构建产物,不检入 git。在仓库根目录执行: + +```sh +pnpm install +pnpm exec tsx scripts/build-exe-for-python-sdk.ts # host platform, ~2 min +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --skip-build # lib/ artifacts already built +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64 +``` + +产物落入 `dist-exe/`,并同步进本包的 `sdk-runtime/src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`),本地构建完成后 SDK 不需要额外设置就能找到可执行文件。`build-exe-for-python-sdk` CI 工作流(手动触发,或给 PR 打 `build-exe` 标签)会测试同样的二进制,但只保留 4 个发布用 wheel 包。exe 内置哪些插件、载体如何组织,见 [sdk-runtime README](sdk-runtime/README.md);构建还会顺带刷新仅供开发使用的 `node` 载体(见下文「对着 Node 源码运行」)。 + +## 用可执行文件验证 SDK + +```sh +export UV_PROJECT_ENVIRONMENT="$PWD/tmp/py-sdk-venv" # keep the venv out of python/ +uv sync --project python/sdk --group test +uv run --project python/sdk pytest python/sdk/tests/test_bundled_runtime.py # boots the real carriers +uv run --project python/sdk pytest # full suite; keyless tests included +``` + +交互式验证(需要环境变量或仓库根 `.env` 中的 `DEEPSEEK_API_KEY`): + +```python +from deepseek_harness import DeepSeekHarness +with DeepSeekHarness() as harness: + print(harness.run("say hi").final_response) # auto-resolution picks the bundled exe +``` + +## 对着 Node 源码运行 SDK(不用可执行文件) + +两种方式,均面向仓库成员: + +- **已构建的 `node` 载体**——设置 `DSH_RUNTIME_MODE=node`,SDK 会用系统 Node(>= 22.19)运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js`。这棵树每次运行构建脚本都会刷新,与 exe 打入 pkg 虚拟文件系统(VFS)的是同一份依赖闭包,因此插件语义一致。它不会被自动选中,也不进入分发物。 +- **未构建的源码(tsx)**——把客户端直接指向 `bin` 的 TypeScript 源码,用于编辑、运行和调试:`launch_args_override=("./node_modules/.bin/tsx", "packages/ui/jsonrpc-agent/src/bin.ts")`,`cwd` 设为仓库根,再通过 `cordis=...` 传入配置(或使用默认配置注入)。[sdk/tests/manual_sdk_agent_smoke.py](sdk/tests/manual_sdk_agent_smoke.py) 是现成范例。 + +## 分发 Python 包 + +根目录 [`package.json`](../package.json) 的版本是两个 Python 分发物的权威版本。统一暂存脚本读取这个版本并注入两个 wheel 包,同时在 SDK 元数据中钉死相同版本的 `deepseek-harness-runtime-bin==X.Y.Z`;可选的 `python-vX.Y.Z` 发布标签只有与仓库版本匹配时才会被接受。纯 SDK wheel 包只构建一次,运行时 wheel 包则在每个原生平台各构建一个: + +```sh +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py --package runtime --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=="$version" +``` + +运行时分发物只提供 wheel 包,并拒绝 sdist 构建、缺失可执行文件以及混合平台载荷。三个 wheel 包标签分别是 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;SDK 保持 `py3-none-any`。匹配的 `python-vX.Y.Z` 标签流水线统一构建并发布这 4 个互不冲突的文件,因此常规的 `pip install deepseek-harness==X.Y.Z` 会选中匹配平台的运行时 wheel 包,`import deepseek_harness` 不需要 `runtime_bin`。 + +## 零配置语义 + +运行时二进制本身始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为首个 argv 参数的配置路径),没有内置兜底,也只启动配置里列出的内容。零配置是 SDK 包装层的行为:调用方没有使用任何显式通道时,客户端把运行时包检入的默认配置([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` 完全无需密钥(对端是 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 new file mode 100644 index 0000000000..f369d13463 --- /dev/null +++ b/python/sdk-runtime/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 4beac57526761bb150e90b60f0030ed311c4034d +README.zh.md: c0cc0eef6a9b569105408d2f2e6321795493036a diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md new file mode 100644 index 0000000000..4beac57526 --- /dev/null +++ b/python/sdk-runtime/README.md @@ -0,0 +1,29 @@ +# DeepSeek Harness Runtime Wheel + +English | [中文](README.zh.md) + +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. + +## Runtime carriers + +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 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 install the matching platform runtime wheel produced by the `build-exe-for-python-sdk` CI workflow. The workflow retains wheels rather than standalone executable archives. 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. The repository root `package.json` supplies the shared version for this package and the SDK, and a `python-vX.Y.Z` release tag must match it. + +## 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. +- `bundled_runtime_path() -> Path` — the platform exe path (exe carrier only; the node carrier has no single-path equivalent and launches via the argv tuple above). +- `bundled_default_config_path() -> Path` — the checked-in default config (see below). +- `bundled_package_dir() -> Path` — the installed package data root. + +## Zero-config design + +The runtime binary always demands an explicit config (`$DSH_CORDIS_CONFIG`, or a config path as an argv positional argument) and exits loudly without one — that hard semantic is part of the runtime's design and this package does not soften it. The bin (`dsh-jsonrpc-agent`) boots only the plugins the config lists; the serving surface (the stdio JSON-RPC server) is itself one of its entries (`@deepseek-ai/dsh-jsonrpc`), and without it the booted agent has no channel to the outside. This package checks in `runtime/cordis.yml` (the JSON-RPC serving entry, agent core, preloaded DeepSeek adapter, JSONL session persistence, local bash, each parameterized by the `DSH_*` env vars the SDK sets); when the caller uses no explicit config channel, the `deepseek_harness` client injects that file's path via `DSH_CORDIS_CONFIG` (injection conditions: [sdk README](../sdk/README.md)). Zero-config is thus an explicit, visible parameter pass in the wrapper, not a hidden fallback in the runtime. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md new file mode 100644 index 0000000000..c0cc0eef6a --- /dev/null +++ b/python/sdk-runtime/README.zh.md @@ -0,0 +1,29 @@ +# DeepSeek Harness 运行时 wheel 包 + +[English](README.md) | 中文 + +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 + +## 运行时载体 + +两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: + +- **exe(生产)**——单文件可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 +- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`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 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 + +载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或安装 `build-exe-for-python-sdk` CI 工作流生成的对应平台运行时 wheel 包。该工作流只保留 wheel 包,不保留独立 exe 归档。获取策略与查找接口刻意分离,之后可以换成按需下载而不改动任何调用方。 + +每个 wheel 包只包含一个可执行文件。固定标签为 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 与 `py3-none-macosx_11_0_arm64`;构建钩子会拒绝 `py3-none-any`、可执行文件缺失或重复以及不支持的平台标签。仓库根目录的 `package.json` 为本包和 SDK 提供共同版本,`python-vX.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` 载体必须显式选用,从而生产部署绝不会悄悄跑在源码构建上。 +- `bundled_runtime_path() -> Path`——平台 exe 路径(仅 exe 载体;`node` 载体没有单一路径的等价物,经由上面的 argv 元组启动)。 +- `bundled_default_config_path() -> Path`——检入的默认配置(见下文)。 +- `bundled_package_dir() -> Path`——已安装包的数据根目录。 + +## 零配置设计 + +运行时二进制始终要求显式配置(`$DSH_CORDIS_CONFIG`,或作为 argv 位置参数的配置路径),缺了就报错退出——这一硬语义是运行时设计的一部分,本包不软化它。`bin`(`dsh-jsonrpc-agent`)只启动配置里列出的插件;对外服务接口(stdio JSON-RPC 服务器)也是其中一个条目(`@deepseek-ai/dsh-jsonrpc`),缺了它,启动出的 agent 就没有对外通道。本包检入 `runtime/cordis.yml`(JSON-RPC 服务条目、`agent-core`、预载的 DeepSeek 适配器、JSONL 会话持久化、本地 bash,各项由 SDK 设置的 `DSH_*` 环境变量参数化);调用方未使用任何显式配置通道时,`deepseek_harness` 客户端把该文件路径注入 `DSH_CORDIS_CONFIG`(注入条件见 [sdk README](../sdk/README.md))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py new file mode 100644 index 0000000000..1c5b22e11a --- /dev/null +++ b/python/sdk-runtime/hatch_build.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import os +import platform +import stat +from pathlib import Path + +from hatchling.builders.hooks.plugin.interface import BuildHookInterface + + +_PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def _host_platform_tag() -> str: + machine = platform.machine().lower() + arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine + system = platform.system().lower() + key = f"macos-{arch}" if system == "darwin" else f"linux-{arch}" if system == "linux" else system + try: + return _PLATFORMS[key][0] + except KeyError as exc: + raise RuntimeError(f"unsupported deepseek-harness-runtime-bin build platform: {key}") from exc + + +class RuntimeBuildHook(BuildHookInterface): + """Assign the native wheel tag and reject incomplete or mixed-platform payloads.""" + + def initialize(self, version: str, build_data: dict[str, object]) -> None: + if version == "editable": + return + if self.target_name == "sdist": + raise RuntimeError( + "deepseek-harness-runtime-bin is wheel-only; build and publish platform wheels only." + ) + + platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() + matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] + if len(matches) != 1: + supported = ", ".join(value[0] for value in _PLATFORMS.values()) + raise RuntimeError( + f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" + ) + expected_executable = matches[0][1] + runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" + executables = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) + if [path.name for path in executables] != [expected_executable]: + found = ", ".join(path.name for path in executables) or "none" + raise RuntimeError( + f"runtime wheel {platform_tag} must contain only {expected_executable}; found {found}" + ) + if executables[0].stat().st_mode & stat.S_IXUSR == 0: + raise RuntimeError(f"runtime executable is not executable: {executables[0]}") + + build_data["pure_python"] = False + build_data["infer_tag"] = False + build_data["tag"] = f"py3-none-{platform_tag}" diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json new file mode 100644 index 0000000000..76e6bf157f --- /dev/null +++ b/python/sdk-runtime/package.json @@ -0,0 +1,74 @@ +{ + "name": "dsh-jsonrpc-agent-pkg", + "description": "Deploy root of the single-exe pipeline and the single source of truth unifying 'which plugins the exe bundles' and 'what the Python runtime distributes': the dependency list below IS the exe closure. Pure manifest — no code; a deploy materializes only this package.json plus node_modules.", + "version": "0.0.1", + "private": true, + "type": "module", + "dependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-core": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-hooks-claude": "workspace:^", + "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jsonrpc": "workspace:^", + "@deepseek-ai/dsh-jsonrpc-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-guard": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-acp": "workspace:^", + "@deepseek-ai/dsh-subagent-fork": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-fs": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tool-todo": "workspace:^", + "@deepseek-ai/dsh-tool-web": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-local": "workspace:^", + "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", + "@deepseek-ai/dsh-web-search-exa": "workspace:^", + "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", + "cordis": "workspace:^" + } +} diff --git a/python/sdk-runtime/pyproject.toml b/python/sdk-runtime/pyproject.toml new file mode 100644 index 0000000000..d0f1d97bf5 --- /dev/null +++ b/python/sdk-runtime/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +description = "Pinned DeepSeek Harness runtime for the Python SDK" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } + +# Distributions carry the platform executables (build-injected, VCS-ignored — +# hence `artifacts`) and the checked-in runtime/cordis.yml; the dev-only node +# closure under runtime/node/ is explicitly excluded from wheel and sdist. +[tool.hatch.build] +artifacts = ["src/deepseek_harness_runtime/runtime/dsh-jsonrpc-agent-*"] +exclude = ["src/deepseek_harness_runtime/runtime/node"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness_runtime"] + +[tool.hatch.build.targets.wheel.hooks.custom] + +[tool.hatch.build.targets.sdist.hooks.custom] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py new file mode 100644 index 0000000000..9076861e88 --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -0,0 +1,151 @@ +"""Locate the bundled DeepSeek Harness SDK runtime shipped with this package. + +Two runtime carriers coexist under ``runtime/``, both injected by the repo's +``scripts/build-exe-for-python-sdk.ts`` build (neither is checked into git): + +- **exe (production)**: single-file executables named + ``dsh-jsonrpc-agent-pkg--`` (platform in {linux, macos}, arch in + {x64, arm64}); the target machine needs no Node installation. +- **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, never + selected automatically, and excluded from wheel/sdist distributions. + +``runtime/cordis.yml`` IS checked in: it is the default agent configuration +the client SDK injects via ``$DSH_CORDIS_CONFIG`` for zero-config runs — the +runtime itself always requires an explicit config and has no built-in +fallback. +""" + +from __future__ import annotations + +import os +import platform +import shutil +import sys +from pathlib import Path + +PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" + +RUNTIME_MODE_ENV_VAR = "DSH_RUNTIME_MODE" + +_PLATFORM_TAGS = {"linux": "linux", "darwin": "macos"} +_ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm64"} + +_EXE_ACQUISITION_HINT = ( + "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a " + "deepseek-harness checkout, or install the matching `deepseek-harness-runtime-bin` platform " + "wheel retained by the `build-exe-for-python-sdk` CI workflow. For local development " + "against a repo source build, explicitly select the dev-only node carrier with " + f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))." +) + + +def bundled_package_dir() -> Path: + """Root directory of the installed runtime package data (the directory of this module).""" + root = Path(__file__).resolve().parent + metadata = root / PACKAGE_METADATA_FILENAME + if not metadata.is_file(): + raise FileNotFoundError(f"deepseek-harness-runtime-bin is missing {metadata}") + return root + + +def bundled_default_config_path() -> Path: + """Path of the checked-in default runtime configuration (``runtime/cordis.yml``). + + The client SDK injects this path via ``$DSH_CORDIS_CONFIG`` when the caller + supplies no config and the launch resolves to the bundled runtime — the + runtime binary itself always demands an explicit config. + """ + path = bundled_package_dir() / "runtime" / "cordis.yml" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the default runtime config at {path}" + ) + return path + + +def bundled_runtime_path() -> Path: + """Absolute path of the bundled single-file runtime executable for the current platform. + + Raises FileNotFoundError when the platform is unsupported or the executable + has not been placed into this package; the message names the acquisition + routes (acquisition strategy is deliberately separate from this lookup + interface, so an on-demand download can replace it without touching + callers). + """ + tag = _current_platform_tag() + path = bundled_package_dir() / "runtime" / f"dsh-jsonrpc-agent-pkg-{tag}" + if not path.is_file(): + raise FileNotFoundError( + f"deepseek-harness-runtime-bin is missing the runtime executable at {path}. " + + _EXE_ACQUISITION_HINT + ) + return path + + +def resolve_bundled_launch_args(mode: str | None = None) -> tuple[str, ...]: + """The argv tuple that launches the bundled runtime. + + Mode selection: the explicit ``mode`` argument wins, then the + ``DSH_RUNTIME_MODE`` environment variable (``exe`` | ``node``), then + automatic resolution. Automatic resolution finds the production exe ONLY — + the dev-only node carrier must be selected explicitly so a production + deployment can never silently ride on a source build. Returns + ``(exe_path,)`` in exe mode and ``(node_path, bin_js_path)`` in node mode; + raises FileNotFoundError when the selected carrier is unavailable and + ValueError for an unknown mode value. + """ + selected = mode if mode is not None else os.environ.get(RUNTIME_MODE_ENV_VAR) + if selected is None or selected == "exe": + return (str(bundled_runtime_path()),) + if selected == "node": + return _node_launch_args() + raise ValueError( + f"unsupported DeepSeek Harness runtime mode {selected!r}: expected 'exe' or 'node' " + f"(explicit argument or ${RUNTIME_MODE_ENV_VAR})" + ) + + +def _current_platform_tag() -> str: + plat = _PLATFORM_TAGS.get(sys.platform) + arch = _ARCH_TAGS.get(platform.machine().lower()) + if plat is None or arch is None: + raise FileNotFoundError( + "no bundled dsh-jsonrpc-agent executable exists for this platform " + f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " + "linux/macos on x64/arm64. " + _EXE_ACQUISITION_HINT + ) + return f"{plat}-{arch}" + + +def _node_launch_args() -> tuple[str, str]: + node_root = bundled_package_dir() / "runtime" / "node" + bin_js = ( + node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-agent" / "lib" / "bin.js" + ) + if not bin_js.is_file(): + raise FileNotFoundError( + f"the dev-only node runtime closure is missing at {node_root} " + f"(no {bin_js}); run `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness " + "checkout, which builds and copies the deploy closure here. The node carrier " + "is for repo-local development only — production uses the single-file exe." + ) + node = shutil.which("node") + if node is None: + raise FileNotFoundError( + "the node runtime mode needs a system `node` (>=22.19) on PATH; " + "install Node.js or use the exe mode" + ) + return (node, str(bin_js)) + + +__all__ = [ + "PACKAGE_METADATA_FILENAME", + "RUNTIME_MODE_ENV_VAR", + "bundled_default_config_path", + "bundled_package_dir", + "bundled_runtime_path", + "resolve_bundled_launch_args", +] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json new file mode 100644 index 0000000000..e4d02d8a3d --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/deepseek-harness-runtime.json @@ -0,0 +1 @@ +{"name":"deepseek-harness-runtime-bin","version":"0.0.0-dev"} diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml new file mode 100644 index 0000000000..76ae18b20e --- /dev/null +++ b/python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml @@ -0,0 +1,50 @@ +# Default runtime configuration for the bundled dsh-jsonrpc-agent. The runtime +# binary has NO built-in fallback — it always requires an explicit config via +# `$DSH_CORDIS_CONFIG` (wins) or an argv positional path. The Python client SDK +# injects THIS file's path via `$DSH_CORDIS_CONFIG` when the caller supplies +# no config and the launch resolves to the bundled runtime; that explicit +# injection is what restores the zero-config experience. The runtime bin only +# boots this config; the serving surface (the stdio JSON-RPC server) comes +# from the @deepseek-ai/dsh-jsonrpc entry below. +# +# $DSH_SESSION_ROOT and $DSH_CWD are set by the SDK per launch; the `!!js` +# fallbacks keep this file usable when the runtime is driven manually. + +# The serving surface: HarnessSdkServer + line-delimited JSON-RPC transport on +# stdio. Without this entry the runtime boots an agent nobody can talk to. +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + +# The agent spine bundle: session store, system prompt, tool registry, agent +# registry, and the agent loop. No pre-created agents — the SDK server creates +# one per session/prompt sessionId. +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' + +# The DeepSeek adapter, preloaded for the stock models. The adapter fails loud +# at load without an API key, so keyless boots must still export a dummy +# DEEPSEEK_API_KEY (initialize/shutdown never call the model). baseURL falls +# back to the public endpoint when unset. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + models: + - deepseek-v4-flash + - deepseek-v4-pro + +# JSONL session persistence. $DSH_SESSION_ROOT (set by the SDK whenever +# `session_root` is configured) wins; otherwise ./.sessions relative to the +# runtime process cwd. +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + +# Local bash executor behind the spine's `bash` tool. $DSH_CWD (always set by +# the SDK) wins; otherwise the runtime process cwd. +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml new file mode 100644 index 0000000000..a294dc1194 --- /dev/null +++ b/python/sdk/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: 60540376c5fd85b0852e204bc8bad3f01c849de5 +README.zh.md: 241c06057889f1aa4add6fc54024fba92bd19429 diff --git a/python/sdk/README.md b/python/sdk/README.md new file mode 100644 index 0000000000..60540376c5 --- /dev/null +++ b/python/sdk/README.md @@ -0,0 +1,40 @@ +# DeepSeek Harness Python SDK + +English | [中文](README.zh.md) + +Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The +runtime inherits normal DeepSeek Harness environment variables such as +`DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model +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 + +with DeepSeekHarness() as harness: + result = harness.run("Say hi.") +``` + +`DeepSeekHarness` keeps its lazily started runtime subprocess for reuse across calls. Use it as a context manager, as above, or call `close()` explicitly when finished. + +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 +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`TurnResult.final_response` is the text content from the last +`assistant/message` event in the turn. Use `TurnResult.events` for the complete +event stream, including intermediate assistant messages and tool activity. + +The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. 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 new file mode 100644 index 0000000000..241c060578 --- /dev/null +++ b/python/sdk/README.zh.md @@ -0,0 +1,34 @@ +# DeepSeek Harness Python SDK + +[English](README.md) | 中文 + +通过 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 + +with DeepSeekHarness() as harness: + result = harness.run("Say hi.") +``` + +`DeepSeekHarness` 会保留延迟启动的运行时子进程,以供多次调用复用。请像上例一样将其用作上下文管理器,或在用完后显式调用 `close()`。 + +默认情况下,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 +from deepseek_harness import DeepSeekHarness + +with DeepSeekHarness( + model="deepseek-v4-flash", + cordis="examples/dsbench-coding-agent/cordis.yml", +) as harness: + result = harness.run("Make the requested code change.") +``` + +`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 只暴露真正生效的选项:部署的角色设定与持久化配置归 `cordis.yml` 管理,而 `session_root` 继续作为设置 `DSH_SESSION_ROOT` 的高层便捷选项。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml new file mode 100644 index 0000000000..859a2d6282 --- /dev/null +++ b/python/sdk/pyproject.toml @@ -0,0 +1,31 @@ +[build-system] +requires = ["hatchling>=1.24.0"] +build-backend = "hatchling.build" + +[project] +name = "deepseek-harness" +version = "0.0.0.dev0" +description = "Python SDK for DeepSeek Harness" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "BSD-3-Clause" } +dependencies = [ + "pydantic>=2.12", + "deepseek-harness-runtime-bin==0.0.0.dev0", +] + +[dependency-groups] +test = ["pytest>=8.0"] + +[tool.pytest.ini_options] +addopts = "-q" +testpaths = ["tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/deepseek_harness"] + +# Editable: the runtime package's executables are injected into its source +# tree AFTER install (by scripts/build-exe-for-python-sdk.ts or a manual copy); an +# editable install sees them immediately instead of freezing a wheel snapshot. +[tool.uv.sources] +deepseek-harness-runtime-bin = { path = "../sdk-runtime", editable = true } diff --git a/python/sdk/src/deepseek_harness/__init__.py b/python/sdk/src/deepseek_harness/__init__.py new file mode 100644 index 0000000000..fab791d4f6 --- /dev/null +++ b/python/sdk/src/deepseek_harness/__init__.py @@ -0,0 +1,17 @@ +from .api import DeepSeekHarness, DeepSeekHarnessConfig, Session, TurnResult +from .client import HarnessClient, HarnessConfig +from .models import IncomingRequest, InitializeResponse, JsonObject, Notification, ServerInfo + +__all__ = [ + "DeepSeekHarness", + "DeepSeekHarnessConfig", + "Session", + "TurnResult", + "HarnessClient", + "HarnessConfig", + "IncomingRequest", + "InitializeResponse", + "JsonObject", + "Notification", + "ServerInfo", +] diff --git a/python/sdk/src/deepseek_harness/api.py b/python/sdk/src/deepseek_harness/api.py new file mode 100644 index 0000000000..3743371cea --- /dev/null +++ b/python/sdk/src/deepseek_harness/api.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import uuid +from dataclasses import dataclass, field +from pathlib import Path +from typing import Callable + +from .client import HarnessClient, HarnessConfig +from .models import JsonObject, Notification + + +@dataclass(slots=True) +class DeepSeekHarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime. + + The runtime inherits the caller's environment by default, so existing + DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL settings keep working. Use ``env`` to + intentionally override or inject variables for a subprocess. + """ + + model: str = "deepseek-v4-flash" + cwd: str | None = None + runtime_cwd: str | None = None + session_root: str | None = None + cordis: str | None = None + env: dict[str, str] = field(default_factory=dict) + runtime_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + base_url: str | None = None + api_key: str | None = None + + +@dataclass(slots=True) +class TurnResult: + session_id: str + status: str + final_response: str + events: list[JsonObject] + notifications: list[Notification] + session_root: str | None = None + + +class DeepSeekHarness: + """Reusable synchronous SDK for running DeepSeek Harness agent turns. + + The runtime subprocess starts lazily and remains owned by this instance + across calls to :meth:`run`. Use the instance as a context manager, or call + :meth:`close` explicitly when finished, so the subprocess is always reaped. + """ + + def __init__(self, config: DeepSeekHarnessConfig | None = None, **kwargs: object) -> None: + if config is not None and kwargs: + raise TypeError("pass either DeepSeekHarnessConfig or keyword options, not both") + self.config = config or DeepSeekHarnessConfig(**kwargs) + cwd = str(Path(self.config.cwd or Path.cwd()).resolve()) + runtime_cwd = str(Path(self.config.runtime_cwd).resolve()) if self.config.runtime_cwd is not None else cwd + self._cwd = cwd + env = dict(self.config.env) + if self.config.session_root is not None: + env["DSH_SESSION_ROOT"] = self.config.session_root + if self.config.cordis is not None: + env["DSH_CORDIS_CONFIG"] = self.config.cordis + env["DSH_CWD"] = cwd + if self.config.base_url is not None: + env["DEEPSEEK_BASE_URL"] = self.config.base_url + if self.config.api_key is not None: + env["DEEPSEEK_API_KEY"] = self.config.api_key + + self._client = HarnessClient( + HarnessConfig( + runtime_bin=self.config.runtime_bin, + launch_args_override=self.config.launch_args_override, + cwd=runtime_cwd, + env=env, + request_timeout_seconds=self.config.request_timeout_seconds, + shutdown_timeout_seconds=self.config.shutdown_timeout_seconds, + ) + ) + self._initialized = False + + def __enter__(self) -> "DeepSeekHarness": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + @property + def client(self) -> HarnessClient: + return self._client + + def start(self) -> None: + if self._initialized: + return + self._client.start() + self._client.initialize( + cwd=self._cwd, + model=self.config.model, + ) + self._initialized = True + + def close(self) -> None: + self._client.close() + self._initialized = False + + def start_session(self, session_id: str | None = None) -> "Session": + self.start() + return Session(self, session_id or f"session-{uuid.uuid4().hex}") + + def run( + self, + input: str | list[JsonObject], + *, + session_id: str | None = None, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + return self.start_session(session_id).run(input, on_notification=on_notification) + + +class Session: + def __init__(self, harness: DeepSeekHarness, session_id: str) -> None: + self.harness = harness + self.id = session_id + + def run( + self, + input: str | list[JsonObject], + *, + on_notification: Callable[[Notification], None] | None = None, + ) -> TurnResult: + content_blocks = normalize_input(input) + notifications: list[Notification] = [] + events: list[JsonObject] = [] + status = "error" + finished = False + + def collect(notification: Notification) -> None: + nonlocal finished, status + notifications.append(notification) + if on_notification is not None: + on_notification(notification) + if notification.method == "session.event": + event = notification.payload.get("event") + if isinstance(event, dict): + events.append(event) + if notification.method == "session.finished" and notification.payload.get("sessionId") == self.id: + status = str(notification.payload.get("status") or "ok") + finished = True + + with self.harness.client.subscribe_session_notifications(self.id) as subscription: + self.harness.client.session_prompt( + self.id, + content_blocks, + on_notification=collect, + notification_subscription=subscription, + ) + + while not finished: + notification = subscription.next() + collect(notification) + + return TurnResult( + session_id=self.id, + status=status, + final_response=final_response(events), + events=events, + notifications=notifications, + session_root=self.harness.config.session_root, + ) + + +def normalize_input(input: str | list[JsonObject]) -> list[JsonObject]: + if isinstance(input, str): + return [{"type": "text", "text": input}] + return input + + +def final_response(events: list[JsonObject]) -> str: + for event in reversed(events): + if event.get("type") != "assistant/message": + continue + data = event.get("data") + if not isinstance(data, dict): + continue + content = data.get("content") + if not isinstance(content, list): + continue + parts: list[str] = [] + for block in content: + if isinstance(block, dict) and block.get("type") == "text": + parts.append(str(block.get("text") or "")) + return "".join(parts) + return "" diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py new file mode 100644 index 0000000000..be457c538a --- /dev/null +++ b/python/sdk/src/deepseek_harness/client.py @@ -0,0 +1,511 @@ +from __future__ import annotations + +import json +import os +import queue +import subprocess +import threading +import time +import uuid +from collections import deque +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Literal, TypeAlias, TypeVar + +from pydantic import BaseModel + +from .errors import JsonRpcError, TransportClosedError +from .models import IncomingRequest, InitializeResponse, JsonObject, JsonValue, Notification + +ModelT = TypeVar("ModelT", bound=BaseModel) +NotificationFilter: TypeAlias = Callable[[Notification], bool] + + +@dataclass(slots=True) +class HarnessConfig: + """Configuration for launching the local DeepSeek Harness SDK runtime.""" + + runtime_bin: str | None = None + bridge_bin: str | None = None + launch_args_override: tuple[str, ...] | None = None + cwd: str | None = None + env: dict[str, str] | None = None + request_timeout_seconds: float | None = None + shutdown_timeout_seconds: float | None = 1.0 + + +class HarnessClient: + """Synchronous JSON-RPC client for the DeepSeek Harness SDK runtime over stdio.""" + + def __init__(self, config: HarnessConfig | None = None) -> None: + self.config = config or HarnessConfig() + self._proc: subprocess.Popen[str] | None = None + self._lock = threading.Lock() + self._write_lock = threading.Lock() + self._responses: dict[str, queue.Queue[JsonValue | BaseException]] = {} + self._notifications: queue.Queue[Notification | BaseException] = queue.Queue() + self._notification_subscribers: dict[ + str, tuple[queue.Queue[Notification | BaseException], NotificationFilter | None] + ] = {} + self._requests: queue.Queue[IncomingRequest | BaseException] = queue.Queue() + self._stderr_lines: deque[str] = deque(maxlen=400) + self._reader_thread: threading.Thread | None = None + self._stderr_thread: threading.Thread | None = None + + def __enter__(self) -> "HarnessClient": + self.start() + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def start(self) -> None: + if self._proc is not None: + return + args = list(self.config.launch_args_override or self._default_launch_args()) + env = os.environ.copy() + if self.config.env: + env.update(self.config.env) + self._inject_bundled_default_config(env) + self._proc = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + cwd=None if self.config.cwd is None else str(Path(self.config.cwd).resolve()), + env=env, + bufsize=1, + ) + self._start_reader_thread() + self._start_stderr_thread() + + def close(self) -> None: + proc = self._proc + if proc is None: + return + try: + self.request("shutdown", None, response_model=_ShutdownResponse, timeout_seconds=self.config.shutdown_timeout_seconds) + except Exception as exc: + self._stderr_lines.append(f"shutdown request failed: {exc}") + if proc.stdin: + try: + proc.stdin.close() + except Exception as exc: + self._stderr_lines.append(f"stdin close failed: {exc}") + if proc.poll() is None: + try: + proc.terminate() + except ProcessLookupError: + pass + try: + proc.wait(timeout=self.config.shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait() + self._proc = None + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime closed")) + if self._reader_thread and self._reader_thread.is_alive(): + self._reader_thread.join(timeout=0.5) + if self._stderr_thread and self._stderr_thread.is_alive(): + self._stderr_thread.join(timeout=0.5) + + def initialize( + self, + *, + cwd: str, + model: str, + ) -> InitializeResponse: + payload: JsonObject = { + "cwd": str(Path(cwd).resolve()), + "model": model, + } + try: + return self.request("initialize", payload, response_model=InitializeResponse) + except BaseException: + self.close() + raise + + def session_prompt( + self, + session_id: str, + content_blocks: list[JsonObject], + *, + on_notification: Callable[[Notification], None] | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> None: + payload: JsonObject = {"sessionId": session_id, "contentBlocks": content_blocks} + self.request( + "session/prompt", + payload, + response_model=_SessionPromptResponse, + on_notification=on_notification, + notification_filter=_notification_belongs_to_session(session_id), + notification_subscription=notification_subscription, + ) + + def request( + self, + method: str, + params: JsonObject | None, + *, + response_model: type[ModelT], + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> ModelT: + result = self._request_raw( + method, + params, + timeout_seconds=timeout_seconds, + on_notification=on_notification, + notification_filter=notification_filter, + notification_subscription=notification_subscription, + ) + if not isinstance(result, dict): + raise TypeError(f"{method} response must be a JSON object") + return response_model.model_validate(result) + + def notify(self, method: str, params: JsonObject | None = None) -> None: + message: JsonObject = {"jsonrpc": "2.0", "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + + def next_notification(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def subscribe_notifications( + self, + notification_filter: NotificationFilter | None = None, + ) -> "NotificationSubscription": + subscription_id = str(uuid.uuid4()) + notifications: queue.Queue[Notification | BaseException] = queue.Queue() + with self._lock: + self._notification_subscribers[subscription_id] = (notifications, notification_filter) + return NotificationSubscription(self, subscription_id, notifications) + + def subscribe_session_notifications(self, session_id: str) -> "NotificationSubscription": + return self.subscribe_notifications(_notification_belongs_to_session(session_id)) + + def next_request(self) -> IncomingRequest: + item = self._requests.get() + if isinstance(item, BaseException): + raise item + return item + + def respond(self, request_id: str | int, result: JsonValue) -> None: + self._write_message({"jsonrpc": "2.0", "id": request_id, "result": result}) + + def respond_error( + self, + request_id: str | int, + *, + code: int, + message: str, + data: JsonValue | None = None, + ) -> None: + error: JsonObject = {"code": code, "message": message} + if data is not None: + error["data"] = data + self._write_message({"jsonrpc": "2.0", "id": request_id, "error": error}) + + def _request_raw( + self, + method: str, + params: JsonObject | None = None, + *, + timeout_seconds: float | None = None, + on_notification: Callable[[Notification], None] | None = None, + notification_filter: NotificationFilter | None = None, + notification_subscription: "NotificationSubscription | None" = None, + ) -> JsonValue: + request_id = str(uuid.uuid4()) + waiter: queue.Queue[JsonValue | BaseException] = queue.Queue(maxsize=1) + temp_subscription: NotificationSubscription | None = None + subscription = notification_subscription + with self._lock: + self._responses[request_id] = waiter + if on_notification is not None and subscription is None: + temp_subscription = self.subscribe_notifications(notification_filter) + subscription = temp_subscription + try: + message: JsonObject = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + self._write_message(message) + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + timeout = self.config.request_timeout_seconds if timeout_seconds is None else timeout_seconds + deadline = None if timeout is None else time.monotonic() + timeout + try: + while True: + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + wait_timeout = None + if on_notification is not None: + wait_timeout = 0.05 + if deadline is not None: + remaining = deadline - time.monotonic() + if remaining <= 0: + with self._lock: + self._responses.pop(request_id, None) + raise TimeoutError(f"{method} timed out waiting for DeepSeek Harness runtime") + wait_timeout = remaining if wait_timeout is None else min(wait_timeout, remaining) + try: + item = waiter.get(timeout=wait_timeout) + if on_notification is not None and subscription is not None: + subscription.drain(on_notification) + break + except queue.Empty: + continue + except BaseException: + with self._lock: + self._responses.pop(request_id, None) + if temp_subscription is not None: + temp_subscription.close() + raise + finally: + if temp_subscription is not None: + temp_subscription.close() + if isinstance(item, BaseException): + raise item + return item + + def _write_message(self, message: JsonObject) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + raise TransportClosedError("DeepSeek Harness runtime is not running") + try: + payload = json.dumps(message, separators=(",", ":")) + "\n" + with self._write_lock: + proc.stdin.write(payload) + proc.stdin.flush() + except Exception as exc: + raise self._runtime_closed_error("Failed to write to DeepSeek Harness runtime") from exc + + def _start_reader_thread(self) -> None: + self._reader_thread = threading.Thread(target=self._reader_loop, name="dsh-runtime-reader", daemon=True) + self._reader_thread.start() + + def _start_stderr_thread(self) -> None: + self._stderr_thread = threading.Thread(target=self._stderr_loop, name="dsh-runtime-stderr", daemon=True) + self._stderr_thread.start() + + def _reader_loop(self) -> None: + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + for line in proc.stdout: + if not line.strip(): + continue + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + self._handle_message(message) + except BaseException as exc: + self._fail_waiters(exc) + finally: + self._fail_waiters(self._runtime_closed_error("DeepSeek Harness runtime stdout closed")) + + def _stderr_loop(self) -> None: + proc = self._proc + if proc is None or proc.stderr is None: + return + for line in proc.stderr: + self._stderr_lines.append(line.rstrip()) + + def _handle_message(self, message: object) -> None: + if not isinstance(message, dict): + return + msg_id = message.get("id") + method = message.get("method") + if isinstance(msg_id, (str, int)) and isinstance(method, str): + params = message.get("params") + self._requests.put(IncomingRequest(id=msg_id, method=method, payload=params if isinstance(params, dict) else {})) + return + if isinstance(msg_id, (str, int)): + with self._lock: + waiter = self._responses.pop(str(msg_id), None) + if waiter is None: + return + if isinstance(message.get("error"), dict): + err = message["error"] + waiter.put(JsonRpcError(_int_or_none(err.get("code")), str(err.get("message", "JSON-RPC error")), err.get("data"))) + else: + waiter.put(message.get("result")) + return + if isinstance(method, str): + params = message.get("params") + notification = Notification(method=method, payload=params if isinstance(params, dict) else {}) + with self._lock: + subscribers = list(self._notification_subscribers.items()) + delivered = False + for subscription_id, (subscriber, predicate) in subscribers: + try: + matches = predicate is None or predicate(notification) + except BaseException as exc: + with self._lock: + current = self._notification_subscribers.get(subscription_id) + if current is not None and current[0] is subscriber: + self._notification_subscribers.pop(subscription_id, None) + subscriber.put(exc) + continue + if matches: + subscriber.put(notification) + delivered = True + if not delivered: + self._notifications.put(notification) + + def _fail_waiters(self, exc: BaseException) -> None: + with self._lock: + waiters = list(self._responses.values()) + self._responses.clear() + subscribers = list(self._notification_subscribers.values()) + self._notification_subscribers.clear() + for waiter in waiters: + waiter.put(exc) + for subscriber, _predicate in subscribers: + subscriber.put(exc) + self._notifications.put(exc) + self._requests.put(exc) + + def _runtime_closed_error(self, reason: str) -> TransportClosedError: + proc = self._proc + if ( + proc is not None + and proc.poll() is not None + and self._stderr_thread is not None + and self._stderr_thread.is_alive() + and threading.current_thread() is not self._stderr_thread + ): + self._stderr_thread.join(timeout=0.1) + + parts = [reason] + if proc is not None: + exit_code = proc.poll() + if exit_code is not None: + parts.append(f"exit code: {exit_code}") + if self._stderr_lines: + parts.append("stderr tail:\n" + "\n".join(self._stderr_lines)) + return TransportClosedError("\n".join(parts)) + + def _default_launch_args(self) -> tuple[str, ...]: + if self.config.runtime_bin is not None: + return (self.config.runtime_bin,) + if self.config.bridge_bin is not None: + return (self.config.bridge_bin,) + try: + from deepseek_harness_runtime import resolve_bundled_launch_args + except ImportError as exc: + raise FileNotFoundError( + "Unable to locate the bundled DeepSeek Harness SDK runtime. " + "Install deepseek-harness-runtime-bin or set HarnessConfig.runtime_bin." + ) from exc + return resolve_bundled_launch_args() + + def _inject_bundled_default_config(self, env: dict[str, str]) -> None: + """Restore the zero-config experience over the config-mandatory bundled runtime. + + The bundled runtime (single-file exe or the dev-only node closure) + always demands an explicit config. When the launch resolves to the + bundled runtime (no ``runtime_bin`` / ``bridge_bin`` / + ``launch_args_override``) and the merged subprocess environment has no + non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty + value as absent, so this does too — inject the runtime package's + checked-in default cordis.yml. With an explicit runtime or config + channel the client stays out of the way. + """ + uses_bundled_runtime = ( + self.config.launch_args_override is None + and self.config.runtime_bin is None + and self.config.bridge_bin is None + ) + if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"): + return + # Cannot fail: _default_launch_args() already imported the runtime + # package on this (bundled) path, raising the actionable install + # error when it is absent. + from deepseek_harness_runtime import bundled_default_config_path + + env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path()) + + def _unsubscribe_notifications(self, subscription_id: str) -> None: + with self._lock: + self._notification_subscribers.pop(subscription_id, None) + + +class NotificationSubscription: + def __init__( + self, + client: HarnessClient, + subscription_id: str, + notifications: queue.Queue[Notification | BaseException], + ) -> None: + self._client = client + self._subscription_id = subscription_id + self._notifications = notifications + self._closed = False + + def __enter__(self) -> "NotificationSubscription": + return self + + def __exit__(self, _exc_type, _exc, _tb) -> None: + self.close() + + def close(self) -> None: + if self._closed: + return + self._closed = True + self._client._unsubscribe_notifications(self._subscription_id) + + def next(self) -> Notification: + item = self._notifications.get() + if isinstance(item, BaseException): + raise item + return item + + def drain(self, on_notification: Callable[[Notification], None]) -> None: + while True: + try: + item = self._notifications.get_nowait() + except queue.Empty: + return + if isinstance(item, BaseException): + raise item + on_notification(item) + + +class _SessionPromptResponse(BaseModel): + accepted: Literal[True] + + +class _ShutdownResponse(BaseModel): + pass + + +def _int_or_none(value: object) -> int | None: + return value if isinstance(value, int) else None + + +def _notification_belongs_to_session(session_id: str) -> NotificationFilter: + def belongs(notification: Notification) -> bool: + payload = notification.payload + return ( + payload.get("sessionId") == session_id + or payload.get("parentSessionId") == session_id + or payload.get("childSessionId") == session_id + ) + + return belongs diff --git a/python/sdk/src/deepseek_harness/errors.py b/python/sdk/src/deepseek_harness/errors.py new file mode 100644 index 0000000000..84cac8d3ce --- /dev/null +++ b/python/sdk/src/deepseek_harness/errors.py @@ -0,0 +1,19 @@ +from __future__ import annotations + + +class HarnessError(Exception): + """Base exception for SDK and runtime failures.""" + + +class TransportClosedError(HarnessError): + """Raised when the runtime subprocess exits or closes stdout.""" + + +class JsonRpcError(HarnessError): + """Raised when the runtime returns a JSON-RPC error response.""" + + def __init__(self, code: int | None, message: str, data: object | None = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data diff --git a/python/sdk/src/deepseek_harness/models.py b/python/sdk/src/deepseek_harness/models.py new file mode 100644 index 0000000000..f398bb422d --- /dev/null +++ b/python/sdk/src/deepseek_harness/models.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TypeAlias + +from pydantic import BaseModel + +JsonScalar: TypeAlias = str | int | float | bool | None +JsonValue: TypeAlias = JsonScalar | dict[str, "JsonValue"] | list["JsonValue"] +JsonObject: TypeAlias = dict[str, JsonValue] + + +@dataclass(slots=True) +class Notification: + method: str + payload: JsonObject + + +@dataclass(slots=True) +class IncomingRequest: + id: str | int + method: str + payload: JsonObject + + +class ServerInfo(BaseModel): + name: str | None = None + version: str | None = None + + +class InitializeResponse(BaseModel): + serverInfo: ServerInfo | None = None diff --git a/python/sdk/tests/manual_sdk_agent_smoke.py b/python/sdk/tests/manual_sdk_agent_smoke.py new file mode 100644 index 0000000000..d259d7dab7 --- /dev/null +++ b/python/sdk/tests/manual_sdk_agent_smoke.py @@ -0,0 +1,125 @@ +"""Manual keyless smoke: drive the repo-source jsonrpc-agent bin (node + tsx). + +Runs the SDK against `packages/ui/jsonrpc-agent/src/bin.ts` executed from the +repo checkout (requires `pnpm install`; no build, no API key — the model +endpoint is a local mock SSE server). The bin only boots the supplied +cordis.yml — the stdio JSON-RPC server itself comes from the config's +`@deepseek-ai/dsh-jsonrpc` entry — so the runtime package's default cordis.yml +is passed explicitly. Not collected by pytest; run it directly: +`python tests/manual_sdk_agent_smoke.py`. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import tempfile +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from deepseek_harness import DeepSeekHarness +from deepseek_harness_runtime import bundled_default_config_path + + +class MockCompletionHandler(BaseHTTPRequestHandler): + requests: list[dict[str, Any]] = [] + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = self.rfile.read(length).decode("utf-8") + self.requests.append({ + "path": self.path, + "authorization": self.headers.get("authorization"), + "body": json.loads(body), + }) + self.send_response(200) + self.send_header("content-type", "text/event-stream") + self.end_headers() + self.wfile.write(b'data: {"choices":[{"delta":{"role":"assistant","content":null,"reasoning_content":""}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":"SDK runtime reached the configured HTTP model endpoint."}}]}\n\n') + self.wfile.write(b'data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}],"usage":{"prompt_tokens":7,"completion_tokens":9}}\n\n') + self.wfile.write(b"data: [DONE]\n\n") + + def log_message(self, _format: str, *_args: object) -> None: + return + + +def run_smoke(repo_root: Path, keep_sessions: bool) -> None: + session_root = Path(tempfile.mkdtemp(prefix="dsh-sdk-smoke-sessions-")) + runtime_entry = repo_root / "packages/ui/jsonrpc-agent/src/bin.ts" + server = ThreadingHTTPServer(("127.0.0.1", 0), MockCompletionHandler) + thread = threading.Thread(target=server.serve_forever, name="mock-openai-compatible-server", daemon=True) + thread.start() + base_url = f"http://127.0.0.1:{server.server_address[1]}" + + print(f"repo_root={repo_root}") + print(f"session_root={session_root}") + print(f"mock_base_url={base_url}") + + try: + with DeepSeekHarness( + model="sdk-smoke-model", + cwd=str(repo_root / "python/sdk"), + runtime_cwd=str(repo_root), + session_root=str(session_root), + cordis=str(bundled_default_config_path()), + launch_args_override=("node", "--import", "tsx", str(runtime_entry)), + env={ + "DEEPSEEK_BASE_URL": base_url, + "DEEPSEEK_API_KEY": "sdk-smoke-key", + }, + request_timeout_seconds=20, + shutdown_timeout_seconds=2, + ) as harness: + result = harness.run( + "Please reply with a short confirmation and do not call tools.", + session_id="sdk-smoke-main", + ) + print(f"turn_status={result.status}") + print(f"final_response={result.final_response}") + assert result.status == "ok", result + assert "configured HTTP model endpoint" in result.final_response + assert len(MockCompletionHandler.requests) == 1 + request = MockCompletionHandler.requests[0] + print(json.dumps(request, ensure_ascii=False, indent=2)[:4000]) + assert request["authorization"] == "Bearer sdk-smoke-key" + assert request["body"]["model"] == "sdk-smoke-model" + + jsonl_files = sorted(session_root.rglob("*.jsonl")) + assert jsonl_files, f"no jsonl sessions were written under {session_root}" + print("session_jsonl_files:") + for path in jsonl_files: + print(f" {path} bytes={path.stat().st_size}") + with path.open("r", encoding="utf-8") as handle: + first_line = handle.readline().strip() + if first_line: + print(f" first_line={first_line[:500]}") + finally: + server.shutdown() + server.server_close() + + if keep_sessions: + print(f"kept_session_root={session_root}") + else: + shutil.rmtree(session_root) + print("removed temporary session root") + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument( + "--repo-root", + type=Path, + default=Path(__file__).resolve().parents[3], + help="Path to the deepseek-harness checkout.", + ) + parser.add_argument("--keep-sessions", action="store_true") + args = parser.parse_args() + run_smoke(args.repo_root.resolve(), args.keep_sessions) + + +if __name__ == "__main__": + main() diff --git a/python/sdk/tests/test_bundled_runtime.py b/python/sdk/tests/test_bundled_runtime.py new file mode 100644 index 0000000000..a55c6c2c6a --- /dev/null +++ b/python/sdk/tests/test_bundled_runtime.py @@ -0,0 +1,125 @@ +"""Smoke tests against the bundled dsh-jsonrpc-agent artifacts. + +These boot the runtime the way an installed SDK does, once per bundled +carrier: the platform single-file exe (production) and the dev-only node +closure under ``runtime/node`` driven by system ``node``. Each carrier skips +independently when its artifact is absent on this machine — build or fetch it +per the FileNotFoundError guidance quoted in the skip reason. Keyless: the +dummy DEEPSEEK_API_KEY only satisfies the adapter's load-time check; +initialize/shutdown never call a model. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig +from deepseek_harness.errors import TransportClosedError +from deepseek_harness_runtime import resolve_bundled_launch_args + +_MODES = ("exe", "node") + +# The serving surface is itself a plugin: without the dsh-jsonrpc entry the +# runtime boots an agent nobody can talk to and exits 0 on stdin EOF. +_CORDIS_YML = """\ +- 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: './sessions' +- id: bash + name: '@deepseek-ai/dsh-bash-local' + config: + cwd: '.' +- id: todo + name: '@deepseek-ai/dsh-tool-todo' +""" + + +def _launch_args(mode: str) -> tuple[str, ...]: + try: + return resolve_bundled_launch_args(mode) + except FileNotFoundError as exc: + pytest.skip(f"bundled {mode}-mode runtime unavailable on this machine: {exc}") + + +def _client(tmp_path: Path, launch_args: tuple[str, ...]) -> HarnessClient: + return HarnessClient( + HarnessConfig( + launch_args_override=launch_args, + cwd=str(tmp_path), + env={ + "DSH_CORDIS_CONFIG": "./cordis.yml", + "DSH_SESSION_ROOT": str(tmp_path / "sessions"), + "DSH_CWD": str(tmp_path), + # initialize() lazily mounts the llm-deepseek adapter for the + # requested model; a dummy key keeps the keyless boot green + # (initialize/shutdown never call the model). + "DEEPSEEK_API_KEY": "sk-dummy-for-boot", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:9", + }, + request_timeout_seconds=120, + ) + ) + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_boots_a_cordis_config(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text(_CORDIS_YML) + + with _client(tmp_path, launch_args) as client: + init = client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + + assert init.serverInfo is not None + assert init.serverInfo.name == "deepseek-harness-sdk-runtime" + + +@pytest.mark.parametrize("mode", _MODES) +def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode: str) -> None: + launch_args = _launch_args(mode) + (tmp_path / "cordis.yml").write_text( + "- id: missing\n name: '@deepseek-ai/dsh-does-not-exist'\n" + ) + + client = _client(tmp_path, launch_args) + client.start() + try: + with pytest.raises((TransportClosedError, TimeoutError)) as excinfo: + client.initialize(cwd=str(tmp_path), model="deepseek-v4-pro") + finally: + client.close() + + assert "@deepseek-ai/dsh-does-not-exist" in str(excinfo.value) + + +@pytest.mark.parametrize("mode", _MODES) +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) +def test_zero_config_run_injects_bundled_default_cordis_config( + tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch +) -> None: + _launch_args(mode) # skip early when this carrier is unavailable + monkeypatch.setenv("DSH_RUNTIME_MODE", mode) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) + + harness = DeepSeekHarness( + model="deepseek-v4-pro", + cwd=str(tmp_path), + session_root=str(tmp_path / "sessions"), + api_key="sk-dummy-for-boot", + base_url="http://127.0.0.1:9", + request_timeout_seconds=120, + ) + with harness: + # __enter__ boots the runtime, which exits with a usage error unless + # HarnessClient.start() injected the bundled default config over the + # unset/empty DSH_CORDIS_CONFIG; __exit__ shuts it down. + pass diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py new file mode 100644 index 0000000000..327e5f5c39 --- /dev/null +++ b/python/sdk/tests/test_client.py @@ -0,0 +1,767 @@ +from __future__ import annotations + +import json +import inspect +import sys +import threading +import time +from pathlib import Path + +import pytest + +from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig + + +def test_high_level_sdk_runs_turn_and_collects_final_response(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + env_dump = tmp_path / "env.json" + script.write_text( + """ +import json +import os +import sys + +env_dump = os.environ["ENV_DUMP"] +json.dump({ + "DEEPSEEK_API_KEY": os.environ.get("DEEPSEEK_API_KEY"), + "DEEPSEEK_BASE_URL": os.environ.get("DEEPSEEK_BASE_URL"), + "DSH_CWD": os.environ.get("DSH_CWD"), + "DSH_SESSION_ROOT": os.environ.get("DSH_SESSION_ROOT"), + "DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG"), +}, open(env_dump, "w")) + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.event", + "params": { + "sessionId": params["sessionId"], + "event": { + "type": "assistant/message", + "data": {"content": [{"type": "text", "text": "hello from runtime"}]}, + }, + }, + }), flush=True) + print(json.dumps({ + "jsonrpc": "2.0", + "method": "session.finished", + "params": {"sessionId": params["sessionId"], "status": "ok"}, + }), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + model="deepseek-v4-flash", + cwd=str(tmp_path), + cordis=str(tmp_path / "cordis.yml"), + session_root=str(tmp_path / "sessions"), + launch_args_override=(sys.executable, str(script)), + env={ + "ENV_DUMP": str(env_dump), + "DEEPSEEK_API_KEY": "env-key", + "DEEPSEEK_BASE_URL": "http://127.0.0.1:4321", + }, + ) as harness: + result = harness.run("say hello", session_id="main") + + assert result.status == "ok" + assert result.final_response == "hello from runtime" + assert result.events[0]["type"] == "assistant/message" + dumped_env = json.loads(env_dump.read_text()) + assert dumped_env["DEEPSEEK_API_KEY"] == "env-key" + assert dumped_env["DEEPSEEK_BASE_URL"] == "http://127.0.0.1:4321" + assert dumped_env["DSH_CWD"] == str(tmp_path) + assert dumped_env["DSH_SESSION_ROOT"] == str(tmp_path / "sessions") + assert dumped_env["DSH_CORDIS_CONFIG"] == str(tmp_path / "cordis.yml") + + +def test_session_run_invokes_notification_callback_before_returning(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + seen: list[str] = [] + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + session = harness.start_session("main") + result = session.run( + "spawn a helper", + on_notification=lambda notification: seen.append(notification.method), + ) + + assert result.status == "ok" + assert seen == ["subagent.started", "session.finished"] + + +def test_relative_cwd_is_absolute_in_process_environment_and_wire( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + script = tmp_path / "capture_cwd.py" + capture = tmp_path / "cwd.json" + script.write_text( + """ +import json +import os +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + json.dump({"process": os.getcwd(), "environment": os.environ.get("DSH_CWD"), "wire": msg["params"]["cwd"]}, open(os.environ["CAPTURE"], "w")) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + monkeypatch.chdir(tmp_path) + + with DeepSeekHarness( + cwd=".", + runtime_cwd=".", + launch_args_override=(sys.executable, str(script)), + env={"CAPTURE": str(capture)}, + ): + pass + + expected = str(tmp_path.resolve()) + assert json.loads(capture.read_text()) == { + "process": expected, + "environment": expected, + "wire": expected, + } + + +def test_session_run_includes_subagent_finished_for_parent_session(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.started", "params": {"parentSessionId": "main", "childSessionId": "child"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "subagent.finished", "params": {"parentSessionId": "main", "childSessionId": "child", "status": "ok", "stopReason": "completed"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "main", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("spawn a helper", session_id="main") + + assert result.status == "ok" + assert [notification.method for notification in result.notifications] == [ + "subagent.started", + "subagent.finished", + "session.finished", + ] + + +def test_session_run_ignores_notifications_for_other_sessions(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": "other", "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "wrong session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": "other", "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "right session"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness( + launch_args_override=(sys.executable, str(script)), + cwd=str(tmp_path), + ) as harness: + result = harness.run("stay in your lane", session_id="main") + + assert result.status == "ok" + assert result.final_response == "right session" + assert [notification.payload.get("sessionId") for notification in result.notifications] == ["main", "main"] + + +def test_high_level_session_run_does_not_accumulate_global_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": params["sessionId"], "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "ok"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": params["sessionId"], "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + result = harness.run("one turn", session_id="main") + assert result.status == "ok" + assert harness.client._notifications.qsize() == 0 + + +def test_session_run_waits_for_late_finished_without_replaying_stale_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_runtime.py" + script.write_text( + """ +import json +import sys +import time + +turn = 0 +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-runtime"}}}), flush=True) + elif method == "session/prompt": + turn += 1 + params = msg.get("params") or {} + session_id = params["sessionId"] + if turn == 1: + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "first"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + else: + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + time.sleep(0.05) + print(json.dumps({"jsonrpc": "2.0", "method": "session.event", "params": {"sessionId": session_id, "event": {"type": "assistant/message", "data": {"content": [{"type": "text", "text": "second"}]}}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "method": "session.finished", "params": {"sessionId": session_id, "status": "ok"}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with DeepSeekHarness(launch_args_override=(sys.executable, str(script)), cwd=str(tmp_path)) as harness: + first = harness.run("first turn", session_id="main") + second = harness.run("second turn", session_id="main") + + assert first.final_response == "first" + assert second.final_response == "second" + assert [notification.payload.get("sessionId") for notification in second.notifications] == ["main", "main"] + + +def test_client_starts_subprocess_sends_requests_and_routes_notifications(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + params = msg.get("params") or {} + print(json.dumps({"jsonrpc": "2.0", "method": "llm/request", "params": {"requestId": "req-1", "sessionId": params["sessionId"], "model": "dsagent", "messages": []}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + client.session_prompt("main", [{"type": "text", "text": "fix it"}]) + notification = client.next_notification() + assert notification.method == "llm/request" + assert notification.payload["requestId"] == "req-1" + assert notification.payload["sessionId"] == "main" + + +def test_client_keeps_unmatched_notifications_available_globally_while_subscribed() -> None: + client = HarnessClient() + with client.subscribe_session_notifications("main"): + client._handle_message({ + "jsonrpc": "2.0", + "method": "session.event", + "params": {"sessionId": "other", "event": {"type": "assistant/message"}}, + }) + + assert client._notifications.qsize() == 1 + notification = client._notifications.get_nowait() + assert not isinstance(notification, BaseException) + assert notification.method == "session.event" + assert notification.payload["sessionId"] == "other" + + +def test_client_contains_notification_filter_failure_to_its_subscription(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method in {"emit-first", "emit-second"}: + print(json.dumps({"jsonrpc": "2.0", "method": "tick", "params": {"source": method}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": True}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + def broken_filter(_notification: object) -> bool: + raise RuntimeError("bad notification filter") + + with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + client.initialize(cwd="/workspace", model="dsagent") + with ( + client.subscribe_notifications(broken_filter) as broken, + client.subscribe_notifications(lambda notification: notification.method == "tick") as healthy, + ): + client.notify("emit-first") + with pytest.raises(RuntimeError, match="bad notification filter"): + broken.next() + assert healthy.next().payload == {"source": "emit-first"} + assert client._notifications.qsize() == 0 + + client.session_prompt("main", [{"type": "text", "text": "reader still works"}]) + client.notify("emit-second") + assert healthy.next().payload == {"source": "emit-second"} + + +def test_client_rejects_unaccepted_session_prompt_response(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif method == "session/prompt": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"accepted": False}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) as client: + client.initialize(cwd="/workspace", model="dsagent") + with pytest.raises(ValueError): + client.session_prompt("main", [{"type": "text", "text": "fix it"}]) + + +def test_client_routes_bridge_requests_and_sends_responses(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + method = msg.get("method") + if method == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + print(json.dumps({"jsonrpc": "2.0", "id": "bridge-req-1", "method": "llm.request", "params": {"requestId": "req-1", "sessionId": "main", "model": "dsagent", "messages": []}}), flush=True) + elif "id" in msg and "method" not in msg: + print(json.dumps({"jsonrpc": "2.0", "method": "response/seen", "params": {"result": msg.get("result")}}), flush=True) + elif method == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + + request = client.next_request() + assert request.id == "bridge-req-1" + assert request.method == "llm.request" + assert request.payload["requestId"] == "req-1" + + client.respond(request.id, {"content_blocks": [{"type": "text", "text": "done"}]}) + notification = client.next_notification() + assert notification.method == "response/seen" + assert notification.payload["result"]["content_blocks"][0]["text"] == "done" + + +def test_client_ignores_non_json_stdout_lines(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +print("node warning: experimental loader", flush=True) +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig(launch_args_override=(sys.executable, str(script))) + ) as client: + init = client.initialize(cwd="/workspace", model="dsagent") + assert init.serverInfo.name == "fake-dsh" + + +def test_client_request_times_out_when_bridge_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import time + +time.sleep(60) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=0.1, + ) + ) as client: + start = time.monotonic() + try: + client.initialize(cwd="/workspace", model="dsagent") + except TimeoutError: + assert time.monotonic() - start < 2 + else: + raise AssertionError("initialize should time out") + + +def test_client_close_times_out_when_shutdown_does_not_respond(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import signal +import sys +import time + +signal.signal(signal.SIGTERM, signal.SIG_IGN) + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + time.sleep(60) +""".strip() + ) + + client = HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + shutdown_timeout_seconds=0.1, + ) + ) + client.start() + proc = client._proc + assert proc is not None + client.initialize(cwd="/workspace", model="dsagent") + start = time.monotonic() + client.close() + assert time.monotonic() - start < 2 + assert proc.poll() is not None + assert client._proc is None + + +def test_initialize_failure_reaps_started_runtime(tmp_path: Path) -> None: + script = tmp_path / "rejecting_runtime.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "error": {"code": -32000, "message": "bad initialize"}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client.start() + proc = client._proc + assert proc is not None + + with pytest.raises(Exception, match="bad initialize"): + client.initialize(cwd=".", model="dsagent") + + assert proc.wait(timeout=1) is not None + assert client._proc is None + + +def test_public_signatures_omit_unsupported_wire_parameters() -> None: + from deepseek_harness import DeepSeekHarnessConfig, Session + + assert "session_root" not in inspect.signature(HarnessClient.initialize).parameters + assert "system_prompt" not in inspect.signature(HarnessClient.initialize).parameters + assert "profile" not in inspect.signature(HarnessClient.session_prompt).parameters + assert "profile" not in inspect.signature(DeepSeekHarness.run).parameters + assert "profile" not in inspect.signature(Session.run).parameters + assert "system_prompt" not in DeepSeekHarnessConfig.__dataclass_fields__ + assert "client_name" not in HarnessConfig.__dataclass_fields__ + assert "client_version" not in HarnessConfig.__dataclass_fields__ + + +def test_client_close_is_idempotent_before_and_after_start(tmp_path: Path) -> None: + HarnessClient().close() + + script = tmp_path / "fake_bridge.py" + script.write_text( + """ +import json +import sys + +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + client = HarnessClient(HarnessConfig(launch_args_override=(sys.executable, str(script)))) + client.start() + client.initialize(cwd="/workspace", model="dsagent") + client.close() + client.close() + + +def test_runtime_closed_error_includes_stderr_tail(tmp_path: Path) -> None: + script = tmp_path / "crashing_runtime.py" + script.write_text( + """ +import sys + +print("fatal bridge exploded", file=sys.stderr, flush=True) +sys.exit(42) +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + request_timeout_seconds=2, + ) + ) as client: + with pytest.raises(Exception, match="fatal bridge exploded"): + client.initialize(cwd="/workspace", model="dsagent") + + +def test_client_serializes_concurrent_writes(tmp_path: Path) -> None: + script = tmp_path / "fake_bridge.py" + output = tmp_path / "seen.jsonl" + script.write_text( + """ +import json +import os +import sys + +with open(os.environ["SEEN"], "w") as seen: + for line in sys.stdin: + seen.write(line) + seen.flush() + msg = json.loads(line) + if "id" in msg and msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "fake-dsh"}}}), flush=True) + elif "id" in msg and msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + + with HarnessClient( + HarnessConfig( + launch_args_override=(sys.executable, str(script)), + env={"SEEN": str(output)}, + ) + ) as client: + client.initialize(cwd="/workspace", model="dsagent") + threads = [ + threading.Thread(target=client.notify, args=(f"notice-{index}", {"index": index})) + for index in range(50) + ] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + for line in output.read_text().splitlines(): + json.loads(line) + + +def _install_fake_bundled_runtime( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Path: + """Fake the deepseek-harness-runtime-bin package on sys.path. + + A stub exe that dumps DSH_CORDIS_CONFIG to $ENV_DUMP before serving + initialize/shutdown, plus a module exposing the resolution surface the + client consumes. Returns the fake bundled default config path. + """ + runtime = tmp_path / "dsh-jsonrpc-agent" + runtime.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys + +json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w")) +for line in sys.stdin: + msg = json.loads(line) + if msg.get("method") == "initialize": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {"serverInfo": {"name": "bundled-runtime"}}}), flush=True) + elif msg.get("method") == "shutdown": + print(json.dumps({"jsonrpc": "2.0", "id": msg["id"], "result": {}}), flush=True) + break +""".strip() + ) + runtime.chmod(0o755) + + default_config = tmp_path / "default-cordis.yml" + module_dir = tmp_path / "deepseek_harness_runtime" + module_dir.mkdir() + (module_dir / "__init__.py").write_text( + f""" +def resolve_bundled_launch_args(mode=None): + return ({str(runtime)!r},) + + +def bundled_default_config_path(): + return {str(default_config)!r} +""".strip() + ) + + monkeypatch.syspath_prepend(str(tmp_path)) + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + return default_config + + +@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"]) +def test_client_default_launch_uses_bundled_runtime_and_injects_default_config( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None +) -> None: + env_dump = tmp_path / "env.json" + default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch) + if ambient_config is None: + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + else: + monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config) + + with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client: + init = client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert init.serverInfo.name == "bundled-runtime" + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config) + + +def test_client_respects_explicit_config_over_bundled_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + env_dump = tmp_path / "env.json" + _install_fake_bundled_runtime(tmp_path, monkeypatch) + monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False) + + with HarnessClient( + HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"}) + ) as client: + client.initialize(cwd="/workspace", model="deepseek-v4-pro") + + assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml" + + +def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False) + monkeypatch.setattr(sys, "path", []) + + with pytest.raises(FileNotFoundError, match="Install deepseek-harness-runtime-bin"): + HarnessClient().start() diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py new file mode 100644 index 0000000000..38cb40862d --- /dev/null +++ b/python/sdk/tests/test_release_version.py @@ -0,0 +1,39 @@ +"""Tests for repository-owned Python release versions.""" + +from __future__ import annotations + +import json +import runpy +from pathlib import Path +from types import SimpleNamespace + +import pytest + + +ROOT = Path(__file__).resolve().parents[3] +SCRIPT = ROOT / "scripts" / "build-python-release.py" +build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) + + +def test_repository_version_matches_root_package_json() -> None: + expected = json.loads((ROOT / "package.json").read_text())["version"] + + assert build_python_release.repository_version() == expected + + +def test_release_tag_is_optional_for_non_release_builds() -> None: + build_python_release.validate_release_tag(None, "1.2.3") + + +def test_release_tag_must_match_repository_version() -> None: + build_python_release.validate_release_tag("python-v1.2.3", "1.2.3") + + with pytest.raises(ValueError, match="expected 'python-v1.2.3'"): + build_python_release.validate_release_tag("python-v1.2.4", "1.2.3") + + +def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text('{"version":"1.2.3-dev"}\n') + + with pytest.raises(ValueError, match="must be stable X.Y.Z"): + build_python_release.repository_version(tmp_path) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py new file mode 100644 index 0000000000..4549d4dd29 --- /dev/null +++ b/python/sdk/tests/test_runtime_resolution.py @@ -0,0 +1,43 @@ +"""Keyless tests for the deepseek_harness_runtime resolution API. + +These never launch a runtime, so they run everywhere regardless of which +bundled artifacts are present; the launch-and-boot coverage lives in +``test_bundled_runtime.py``. +""" + +from __future__ import annotations + +import pytest + +from deepseek_harness_runtime import ( + RUNTIME_MODE_ENV_VAR, + bundled_default_config_path, + bundled_package_dir, + resolve_bundled_launch_args, +) + + +def test_default_config_is_shipped_with_the_package() -> None: + path = bundled_default_config_path() + assert path == bundled_package_dir() / "runtime" / "cordis.yml" + assert "@deepseek-ai/dsh-agent-core" in path.read_text() + + +def test_unknown_explicit_mode_fails_loud() -> None: + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args("bogus") + + +def test_unknown_env_mode_fails_loud(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + with pytest.raises(ValueError, match="expected 'exe' or 'node'"): + resolve_bundled_launch_args() + + +def test_explicit_mode_wins_over_env_mode(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(RUNTIME_MODE_ENV_VAR, "bogus") + try: + args = resolve_bundled_launch_args("exe") + except FileNotFoundError: + return # explicit 'exe' was honored; only the artifact is missing + assert args[0].endswith(("-x64", "-arm64")) diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock new file mode 100644 index 0000000000..94219b95ad --- /dev/null +++ b/python/sdk/uv.lock @@ -0,0 +1,321 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "deepseek-harness" +version = "0.0.0.dev0" +source = { editable = "." } +dependencies = [ + { name = "deepseek-harness-runtime-bin" }, + { name = "pydantic" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "deepseek-harness-runtime-bin", editable = "../sdk-runtime" }, + { name = "pydantic", specifier = ">=2.12" }, +] + +[package.metadata.requires-dev] +test = [{ name = "pytest", specifier = ">=8.0" }] + +[[package]] +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts new file mode 100644 index 0000000000..6f29438d42 --- /dev/null +++ b/scripts/build-exe-for-python-sdk.ts @@ -0,0 +1,470 @@ +/** + * Build the single-file SDK runtime executables + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + * + * Every settled decision is hardcoded — the PoC judged @yao-pkg/pkg's + * standard mode unusable for this architecture (its ESM→CJS transform breaks + * every runtime `import()`), so the pipeline is fixed on `--sea` mode, plain + * ESM entry, plain-source assets, and a hoisted (symlink-free) staged tree. + * + * Pipeline — every step fails loud with the command it ran: + * + * 1. `pnpm run build` — all packages emit `lib/` (skippable via --skip-build). + * 2. `pnpm --filter dsh-jsonrpc-agent-pkg deploy` — materialize the + * closure-manifest package (python/sdk-runtime/package.json — the single + * source of truth for the exe's plugin set) into the staging dir + * (cleared first; pnpm refuses a non-empty deploy target). Flags, all + * verified against pnpm 11.7: `--legacy` because the workspace does not + * set `inject-workspace-packages=true`; `node-linker=hoisted` for a plain + * file tree with zero symlinks (the safe shape for pkg's VFS, and it + * physically guarantees a single cordis copy); `auto-install-peers=false` + * so transitive `^0.0.x` peers on unpublished packages never hit the + * registry; `link-workspace-packages=true` so the closure resolves to + * workspace/vendor sources. + * 3. Inject the pkg config into the staged package.json: `bin` = the ESM + * `node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` (SEA mode + * hands it to Node's default ESM loader — no CJS shim), plus whole-tree + * asset globs. The cordis Loader resolves plugins + * through runtime dynamic `import()` of bare package names, so pkg's + * static analysis discovers none of them — the entire staged tree must be + * globbed in explicitly. + * 4. `pnpm dlx @yao-pkg/pkg@ --sea --targets --output + * /dsh-jsonrpc-agent-pkg--` — once per target (SEA mode + * packs a single target per invocation), so each product gets its + * canonical name directly. + * 5. Sync into the Python runtime package + * (python/sdk-runtime/src/deepseek_harness_runtime/runtime/, + * created if missing): each product under its canonical filename (exe + * mode), plus the whole staged closure into runtime/node/ (node mode — + * `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` + * runs it directly; the injected pkg + * fields are harmless to node). dist-exe/ keeps the originals for CI + * artifact upload. + * + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` → host-platform exe into dist-exe/ + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64` + * `pnpm exec tsx scripts/build-exe-for-python-sdk.ts --dry-run` → print the plan without executing + */ + +import { spawn } from 'node:child_process' +import { existsSync, mkdirSync, statSync } from 'node:fs' +import { copyFile, readFile, rm, writeFile } from 'node:fs/promises' +import { basename, join, resolve, sep } from 'node:path' +import { parseArgs } from 'node:util' + +const root = resolve(import.meta.dirname, '..') + +/** + * The deploy root: the closure-manifest package (python/sdk-runtime) whose + * dependencies define the exe's contents; the runnable entry inside the + * closure is {@link ENTRY_BIN}. + */ +const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' +/** The bin entry inside the deployed closure (the dsh-jsonrpc-agent app bin). */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js' +/** Basename of every product; the canonical name appends `--`. */ +const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' +/** Default exe Node major; SEA mode requires >= node22, the repo tracks node24. */ +const DEFAULT_NODE_RANGE = 'node24' +/** Pinned pkg version (the one the PoC and acceptance ran on) for reproducible builds. */ +const PKG_SPEC = '@yao-pkg/pkg@6.21.0' +/** Staging dir for the deployed closure — cleared on every run (gitignored). */ +// (No external staging dir: the deploy target IS the Python runtime's +// node-mode carrier — see PYTHON_RUNTIME_DIR/PYTHON_NODE_SUBDIR.) +/** Product output dir (gitignored). */ +const OUT_DIR = 'dist-exe' +/** + * Python runtime package dir the products are synced into. A parallel change + * owns the directory and its .gitignore; this script's only contract is the + * destination path, so a missing dir is created, never an error. + */ +const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' +/** Subdir of {@link PYTHON_RUNTIME_DIR} carrying the staged closure for node-mode execution. */ +const PYTHON_NODE_SUBDIR = 'node' +/** Deploy-root documentation is not runtime input and violates the generated-directory i18n exclusion if retained. */ +const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] + +/** + * Whole-tree asset globs. The cordis Loader dynamic-imports bare package names + * at runtime, invisible to pkg's static analysis, so every runtime file in the + * closure is listed; SEA mode ships them as plain source in the VFS. Every + * package.json must ride along — bare-name resolution dies without them (the + * json glob would already match, but the manifests are resolution-critical, so + * they get their own explicit entry). + */ +const ASSET_GLOBS = [ + 'package.json', + 'node_modules/**/*.js', + 'node_modules/**/*.cjs', + 'node_modules/**/*.mjs', + 'node_modules/**/package.json', + 'node_modules/**/*.json', + 'node_modules/**/*.node', + 'node_modules/**/*.wasm', +] + +const PLATFORMS = ['linux', 'macos'] as const +const ARCHES = ['x64', 'arm64'] as const +type Platform = (typeof PLATFORMS)[number] +type Arch = (typeof ARCHES)[number] + +/** True when `value` is a supported pkg platform tag. */ +function isPlatform(value: string): value is Platform { + return (PLATFORMS as readonly string[]).includes(value) +} + +/** True when `value` is a supported pkg CPU tag. */ +function isArch(value: string): value is Arch { + return (ARCHES as readonly string[]).includes(value) +} + +/** + * One pkg target triple, e.g. `node24-linux-x64`, as an immutable value. + * Construction goes through {@link Target.parse} (a `--targets` entry) or + * {@link Target.host} (the default), which own all validation. + */ +class Target { + private constructor( + /** pkg Node range (`node`); pins the official base binary pkg pulls. */ + readonly nodeRange: string, + /** + * pkg platform tag. Windows is a documented non-goal + * (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). + */ + readonly platform: Platform, + /** pkg CPU tag. */ + readonly arch: Arch, + ) {} + + /** The pkg `--targets` spec string `--`. */ + get spec(): string { + return `${this.nodeRange}-${this.platform}-${this.arch}` + } + + /** + * Parse and validate one target spec; throws on any malformed component. + * @param spec - the raw triple, e.g. `node24-linux-x64`. + * @returns the parsed target. + */ + static parse(spec: string): Target { + const parts = spec.split('-') + const [nodeRange, platform, arch] = parts + if (parts.length !== 3 || nodeRange === undefined || platform === undefined || arch === undefined) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)} must be --, e.g. node24-linux-x64.`) + } + if (!/^node\d+$/.test(nodeRange)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: node range must look like node24, got ${JSON.stringify(nodeRange)}.`) + } + if (!isPlatform(platform)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: platform must be one of ${PLATFORMS.join(', ')} (Windows is a docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md non-goal), got ${JSON.stringify(platform)}.`) + } + if (!isArch(arch)) { + throw new Error(`build-exe-for-python-sdk: target ${JSON.stringify(spec)}: arch must be one of ${ARCHES.join(', ')}, got ${JSON.stringify(arch)}.`) + } + return new Target(nodeRange, platform, arch) + } + + /** + * The default target when --targets is omitted: the host platform on node24. + * @returns the host target; throws on an unsupported host platform or arch. + */ + static host(): Target { + const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'linux' ? 'linux' : undefined + if (platform === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host platform ${process.platform}; pass --targets explicitly.`) + } + const arch = process.arch === 'x64' || process.arch === 'arm64' ? process.arch : undefined + if (arch === undefined) { + throw new Error(`build-exe-for-python-sdk: unsupported host arch ${process.arch}; pass --targets explicitly.`) + } + return new Target(DEFAULT_NODE_RANGE, platform, arch) + } +} + +/** + * Parsed CLI configuration. {@link BuildCli.parse} is the only constructor + * path — it owns flag parsing, target validation, and the --help / bad-flag + * process exits, so an instance always holds a valid plan. + */ +class BuildCli { + private constructor( + /** Build targets; defaults to the host platform only. */ + readonly targets: readonly Target[], + /** Skip step 1 (`pnpm run build`); lib/ artifacts must already exist. */ + readonly skipBuild: boolean, + /** Print every command and config patch instead of executing. */ + readonly dryRun: boolean, + ) {} + + /** + * Parse argv into a validated configuration. Exits the process for --help + * (code 0, usage) and for unknown/malformed flags (code 1, usage on + * stderr); throws on invalid or colliding targets. + * @param argv - the raw arguments (`process.argv.slice(2)`). + * @returns the parsed, validated configuration. + */ + static parse(argv: string[]): BuildCli { + let values: ReturnType + try { + values = BuildCli.parseRaw(argv) + } catch (error) { + console.error(`build-exe-for-python-sdk: ${error instanceof Error ? error.message : String(error)}\n`) + console.error(BuildCli.usage()) + process.exit(1) + } + if (values.help) { + console.log(BuildCli.usage()) + process.exit(0) + } + const targets = values.targets === undefined + ? [Target.host()] + : values.targets.split(',').map(part => part.trim()).filter(part => part !== '').map(spec => Target.parse(spec)) + if (targets.length === 0) throw new Error('build-exe-for-python-sdk: --targets is empty.') + const seen = new Set() + for (const target of targets) { + const key = `${target.platform}-${target.arch}` + if (seen.has(key)) { + throw new Error(`build-exe-for-python-sdk: duplicate platform-arch ${key} in --targets; canonical product names would collide.`) + } + seen.add(key) + } + return new BuildCli(targets, values['skip-build'], values['dry-run']) + } + + /** The flag grammar in one place; parseArgs throws on any unknown flag. */ + private static parseRaw(argv: string[]) { + return parseArgs({ + args: argv, + options: { + 'targets': { type: 'string' }, + 'skip-build': { type: 'boolean', default: false }, + 'dry-run': { type: 'boolean', default: false }, + 'help': { type: 'boolean', default: false }, + }, + }).values + } + + /** The --help text; also printed under flag-parse errors. */ + private static usage(): string { + return [ + 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', + '', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64.', + ' Default: the host platform only (on node24).', + ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', + ' --dry-run print every command and config patch without executing.', + ' --help print this help.', + '', + 'Settled decisions are hardcoded (docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md): pkg runs in --sea mode', + `(standard mode breaks runtime import()), pinned to ${PKG_SPEC}; the deploy tree is`, + `hoisted/symlink-free; the closure deploys straight into ${PYTHON_RUNTIME_DIR}/${PYTHON_NODE_SUBDIR} and products land in ${OUT_DIR}/.`, + ].join('\n') + } +} + +/** The pnpm executable name for the host OS. */ +function pnpmBin(): string { + return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' +} + +/** + * Render a command line for logs and error messages, quoting arguments that + * contain spaces. + * @param command - the executable. + * @param args - its arguments. + * @returns the printable command line. + */ +function formatCommand(command: string, args: string[]): string { + return [command, ...args].map(part => (part.includes(' ') ? JSON.stringify(part) : part)).join(' ') +} + +/** + * The four-step build pipeline over one parsed CLI. Steps are sequential + * async methods; every subprocess inherits stdio and fails loud with the + * exact command it ran. In --dry-run the command/filesystem layer prints + * what it would do instead of executing. + */ +class SingleExeBuild { + /** + * Absolute staging dir — the Python runtime's node-mode carrier: step 2 + * deploys the closure DIRECTLY here (cleared first; it is a pure build + * product, the checked-in default `cordis.yml` lives one level up), step 4 + * reads it as the pkg input, and node mode runs it in place. + */ + readonly staging = resolve(root, PYTHON_RUNTIME_DIR, PYTHON_NODE_SUBDIR) + /** Absolute product output dir. */ + private readonly outDir = resolve(root, OUT_DIR) + + constructor(private readonly cli: BuildCli) {} + + /** Gate the manifest before spending time compiling or packaging it. */ + async verifyClosure(): Promise { + await this.run('runtime dependency closure', pnpmBin(), ['run', 'verify-runtime-closure']) + } + + /** Step 1: `pnpm run build` — all packages emit `lib/` (skipped via --skip-build). */ + async build(): Promise { + if (this.cli.skipBuild) { + console.log('build-exe-for-python-sdk: skipping pnpm run build (--skip-build)') + return + } + await this.run('build', pnpmBin(), ['run', 'build']) + } + + /** Step 2: clear the staging dir and deploy the bridge closure into it. */ + async deployStaging(): Promise { + if (this.staging === root || root.startsWith(this.staging + sep)) { + throw new Error(`build-exe-for-python-sdk: refusing to clear staging dir ${this.staging}: it contains the repo root.`) + } + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] rm -rf ${this.staging}`) + else await rm(this.staging, { recursive: true, force: true }) + await this.run('deploy', pnpmBin(), [ + '--filter', + DEPLOY_ROOT_PACKAGE, + 'deploy', + '--legacy', + '--prod', + '--config.node-linker=hoisted', + '--config.auto-install-peers=false', + '--config.link-workspace-packages=true', + this.staging, + ]) + if (this.cli.dryRun) { + for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) + } else { + await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true }))) + } + } + + /** Step 3: patch the staged package.json with the bin entry + pkg asset globs. */ + async injectPkgConfig(): Promise { + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } + const manifestPath = join(this.staging, 'package.json') + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) + return + } + if (!existsSync(manifestPath)) { + throw new Error(`build-exe-for-python-sdk: ${manifestPath} missing — pnpm deploy did not produce a staged package.`) + } + if (!existsSync(join(this.staging, ENTRY_BIN))) { + throw new Error(`build-exe-for-python-sdk: ${join(this.staging, ENTRY_BIN)} missing — run without --skip-build so lib/ artifacts exist.`) + } + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`) + console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`) + } + + /** + * Step 4: run @yao-pkg/pkg over the staged tree for ONE target (SEA mode + * packs a single target per invocation) and return the product path. + * @param target - the pkg target triple to build. + * @returns the canonical product path `/dsh-jsonrpc-agent-pkg--`. + */ + async pack(target: Target): Promise { + const product = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) + if (!this.cli.dryRun) mkdirSync(this.outDir, { recursive: true }) + await this.run(`pkg ${target.spec}`, pnpmBin(), [ + 'dlx', + PKG_SPEC, + this.staging, + '--sea', + '--targets', + target.spec, + '--output', + product, + ]) + if (!this.cli.dryRun && !existsSync(product)) { + throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) + } + return product + } + + /** + * Print each product path (and size, when it exists on disk). + * @param products - the product paths returned by {@link pack}. + */ + printProducts(products: string[]): void { + console.log(this.cli.dryRun ? 'build-exe-for-python-sdk: [dry-run] would produce:' : 'build-exe-for-python-sdk: products:') + for (const product of products) { + if (this.cli.dryRun) { + console.log(` ${product}`) + continue + } + const megabytes = statSync(product).size / (1024 * 1024) + console.log(` ${product} (${megabytes.toFixed(1)} MB)`) + } + } + + /** + * Step 5: copy every product into the Python runtime package under its + * canonical filename (exe mode). The node-mode carrier needs no sync — step + * 2 deployed the closure into it directly. dist-exe/ keeps the originals + * for CI artifact upload; the destination dir is created if missing. + * @param products - the product paths returned by {@link pack}. + */ + async syncToPythonRuntime(products: string[]): Promise { + const destDir = resolve(root, PYTHON_RUNTIME_DIR) + if (this.cli.dryRun) { + for (const product of products) { + console.log(`build-exe-for-python-sdk: [dry-run] cp ${product} ${join(destDir, basename(product))}`) + } + return + } + mkdirSync(destDir, { recursive: true }) + for (const product of products) { + const destination = join(destDir, basename(product)) + await copyFile(product, destination) + console.log(`build-exe-for-python-sdk: synced ${destination}`) + } + } + + /** + * Run one pipeline step as a subprocess with inherited stdio; reject — + * carrying the printable command — on spawn failure and non-zero exit + * alike. In --dry-run, print the command instead of executing. + * @param label - the step name used in logs and error messages. + * @param command - the executable. + * @param args - its arguments. + */ + private async run(label: string, command: string, args: string[]): Promise { + const printable = formatCommand(command, args) + if (this.cli.dryRun) { + console.log(`build-exe-for-python-sdk: [dry-run] ${printable}`) + return + } + console.log(`build-exe-for-python-sdk: ${label}: ${printable}`) + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: root, stdio: 'inherit' }) + child.once('error', (error) => { + reject(new Error(`build-exe-for-python-sdk: ${label} failed to spawn: ${error.message} (${printable})`)) + }) + child.once('exit', (code, signal) => { + if (code === 0) { + resolvePromise() + return + } + const cause = code === null ? `signal ${signal ?? 'unknown'}` : `exit code ${code}` + reject(new Error(`build-exe-for-python-sdk: ${label} failed (${cause}): ${printable}`)) + }) + }) + } +} + +/** Entry point: parse the CLI, then await each pipeline step in order. */ +async function main(): Promise { + const cli = BuildCli.parse(process.argv.slice(2)) + const pipeline = new SingleExeBuild(cli) + console.log(`build-exe-for-python-sdk: targets: ${cli.targets.map(target => target.spec).join(', ')}`) + console.log(`build-exe-for-python-sdk: staging: ${pipeline.staging}`) + await pipeline.verifyClosure() + await pipeline.build() + await pipeline.deployStaging() + await pipeline.injectPkgConfig() + const products: string[] = [] + for (const target of cli.targets) products.push(await pipeline.pack(target)) + pipeline.printProducts(products) + await pipeline.syncToPythonRuntime(products) +} + +await main() diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py new file mode 100644 index 0000000000..e0e90aa818 --- /dev/null +++ b/scripts/build-python-release.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +"""Stage and build one Python wheel at the repository version.""" + +from __future__ import annotations + +import argparse +import email +import json +import os +import re +import shutil +import stat +import subprocess +import tempfile +import zipfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +PLATFORMS = { + "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), + "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), + "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), +} + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--package", choices=("sdk", "runtime"), required=True) + parser.add_argument( + "--tag", + help="optional python-vX.Y.Z release tag; it must match package.json", + ) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--platform", choices=tuple(PLATFORMS)) + parser.add_argument("--runtime-exe", type=Path) + args = parser.parse_args() + version = repository_version() + validate_release_tag(args.tag, version) + if args.package == "runtime" and (args.platform is None or args.runtime_exe is None): + parser.error("runtime builds require --platform and --runtime-exe") + if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None): + parser.error("SDK builds do not accept --platform or --runtime-exe") + + output_dir = args.output_dir.resolve() + output_dir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary: + staging = Path(temporary) / args.package + if args.package == "sdk": + stage_sdk(staging, version) + environment = None + expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + else: + platform_tag, executable_name = PLATFORMS[args.platform] + stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) + environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag} + expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl" + command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)] + subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True) + if not expected.is_file(): + raise RuntimeError(f"build did not produce expected wheel: {expected}") + verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform]) + print(expected) + + +def repository_version(root: Path = ROOT) -> str: + package_json = root / "package.json" + try: + payload = json.loads(package_json.read_text()) + except (OSError, json.JSONDecodeError) as error: + raise ValueError(f"could not read repository version from {package_json}") from error + version = payload.get("version") if isinstance(payload, dict) else None + if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None: + raise ValueError( + f"{package_json} version must be stable X.Y.Z, got {version!r}" + ) + return version + + +def validate_release_tag(tag: str | None, version: str) -> None: + if tag is None: + return + expected = f"python-v{version}" + if tag != expected: + raise ValueError( + f"release tag must match repository version: expected {expected!r}, got {tag!r}" + ) + + +def copy_package(source: Path, destination: Path) -> None: + shutil.copytree( + source, + destination, + ignore=shutil.ignore_patterns( + ".venv", + ".pytest_cache", + "__pycache__", + "*.pyc", + "dist", + "node_modules", + "dsh-jsonrpc-agent-pkg-*", + ), + ) + + +def rewrite_version(pyproject: Path, version: str) -> None: + text, count = re.subn( + r'^version = "[^"]+"$', + f'version = "{version}"', + pyproject.read_text(), + count=1, + flags=re.MULTILINE, + ) + if count != 1: + raise RuntimeError(f"could not rewrite version in {pyproject}") + pyproject.write_text(text) + + +def stage_sdk(destination: Path, version: str) -> None: + copy_package(ROOT / "python" / "sdk", destination) + pyproject = destination / "pyproject.toml" + rewrite_version(pyproject, version) + text, count = re.subn( + r'"deepseek-harness-runtime-bin==[^"]+"', + f'"deepseek-harness-runtime-bin=={version}"', + pyproject.read_text(), + count=1, + ) + if count != 1: + raise RuntimeError("SDK must contain exactly one runtime dependency pin") + pyproject.write_text(text) + + +def stage_runtime(destination: Path, version: str, executable: Path, executable_name: str) -> None: + if not executable.is_file(): + raise FileNotFoundError(f"runtime executable does not exist: {executable}") + if executable.stat().st_mode & stat.S_IXUSR == 0: + raise PermissionError(f"runtime executable is not executable: {executable}") + copy_package(ROOT / "python" / "sdk-runtime", destination) + rewrite_version(destination / "pyproject.toml", version) + runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" + runtime_dir.mkdir(parents=True, exist_ok=True) + destination_executable = runtime_dir / executable_name + shutil.copyfile(executable, destination_executable) + destination_executable.chmod(executable.stat().st_mode & 0o777) + + +def verify_wheel( + wheel: Path, + package: str, + version: str, + platform: tuple[str, str] | None, +) -> None: + expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}" + with zipfile.ZipFile(wheel) as archive: + wheel_metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/WHEEL")) + metadata_path = next(name for name in archive.namelist() if name.endswith(".dist-info/METADATA")) + wheel_metadata = email.message_from_bytes(archive.read(wheel_metadata_path)) + metadata = email.message_from_bytes(archive.read(metadata_path)) + if wheel_metadata.get_all("Tag") != [expected_tag]: + raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") + if metadata.get("Version") != version: + raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + executables = [name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name] + if package == "runtime": + assert platform is not None + if len(executables) != 1 or not executables[0].endswith(f"/runtime/{platform[1]}"): + raise RuntimeError(f"{wheel} must contain exactly {platform[1]}, found {executables}") + mode = archive.getinfo(executables[0]).external_attr >> 16 + if mode & stat.S_IXUSR == 0: + raise RuntimeError(f"{wheel} runtime executable lost its executable bit") + elif executables: + raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {executables}") + if package == "sdk": + requirements = metadata.get_all("Requires-Dist") or [] + expected_requirement = f"deepseek-harness-runtime-bin=={version}" + if expected_requirement not in requirements: + raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 2d12a66d8f..a1cbfccd09 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -61,6 +61,9 @@ function readJson(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } +const rootManifest = readJson(join(root, 'package.json')) +const repositoryVersion = rootManifest.version + /** Repo-relative dirs holding a package.json, walked to the configured depth. */ function packageDirs(base: string, depth: number): string[] { if (depth === 1) { @@ -78,7 +81,7 @@ function packageDirs(base: string, depth: number): string[] { function workspaceManifests(): WorkspaceManifest[] { const manifests: WorkspaceManifest[] = [ - { dir: '.', manifest: readJson(join(root, 'package.json')) }, + { dir: '.', manifest: rootManifest }, ] for (const { dir: base, depth } of workspaceGlobs) { @@ -107,7 +110,7 @@ const dshBinPackageFiles = [ const dshWorkerPackageFiles = [ 'lib/index.js', - 'lib/worker.js', + 'lib/worker.cjs', 'lib/types/**/*.d.ts', 'lib/types/**/*.d.ts.map', 'src', @@ -147,8 +150,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (peer && dev && peer !== dev) { errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`) } - if (manifest.version !== '0.0.1') { - errors.push(`${label}: package.json must set "version": "0.0.1"`) + if (manifest.version !== repositoryVersion) { + errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`) } if (manifest.type !== 'module') { errors.push(`${label}: package.json must set "type": "module"`) @@ -205,7 +208,16 @@ function checkHierarchyShape(): string[] { return errors } -const errors = [...workspaceManifests().flatMap(checkWorkspace), ...checkHierarchyShape()] +function checkRepositoryVersion(): string[] { + if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return [] + return ['package.json: version must be stable X.Y.Z'] +} + +const errors = [ + ...checkRepositoryVersion(), + ...workspaceManifests().flatMap(checkWorkspace), + ...checkHierarchyShape(), +] if (errors.length > 0) { console.error(errors.join('\n')) process.exitCode = 1 diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index b26e13a56c..131501abb0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -129,6 +129,7 @@ function gatesForMode(selected: Mode): Gate[] { case 'ci-lint': return [ lintGate(), + pnpmScript('duplication', 'duplication'), ] case 'ci-coverage': return [ @@ -151,7 +152,9 @@ function gatesForMode(selected: Mode): Gate[] { ] case 'pre-push': return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('test', 'test'), + pnpmScript('duplication', 'duplication'), pnpmScript('snapshot', 'test:snapshot'), pnpmScript('build', 'build'), ...hygieneLeafGates({ artifactNeeds: ['build'] }), @@ -163,9 +166,11 @@ function gatesForMode(selected: Mode): Gate[] { function ciPrimaryGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), pnpmScript('typecheck', 'typecheck'), lintGate(), + pnpmScript('duplication', 'duplication'), coverageGate(), pnpmScript('snapshot', 'test:snapshot'), demoSmokeGate({ needs: ['lint'] }), @@ -184,6 +189,7 @@ function ciPrimaryGates(): Gate[] { function ciStaticGates(): Gate[] { return [ + pnpmScript('runtime-closure', 'verify-runtime-closure', { label: 'runtime closure' }), pnpmScript('constraints', 'constraints'), demoSmokeGate(), ...docSyncLeafGates(), @@ -327,7 +333,7 @@ function builtBinSmokeGate(): Gate { 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', // The worker-entry packages' built bundles: the only automated proof - // that lib/index.js resolves its sibling lib/worker.js under plain node + // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). 'packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts', 'packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts', diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py new file mode 100644 index 0000000000..2c61d07c19 --- /dev/null +++ b/scripts/smoke-python-runtime.py @@ -0,0 +1,794 @@ +#!/usr/bin/env python3 +"""Keyless full-turn and snapshot smoke for the Python SDK runtime.""" + +from __future__ import annotations + +import argparse +import difflib +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 TYPE_CHECKING, Callable + +if TYPE_CHECKING: + from deepseek_harness import TurnResult + + +EXPECTED_TEXT = "runtime smoke ok" +CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." +CODE_WORKER_TEXT = "code worker smoke ok" +WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." +WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" +SNAPSHOT_PROMPT = "Run the advanced packaged-runtime snapshot scenario." +SNAPSHOT_SESSION_ID = "advanced-executable" +SNAPSHOT_DIRECT_CHILD_PROMPT = "Reply with exactly DIRECT_CHILD_OK and nothing else." +SNAPSHOT_WORKFLOW_CHILD_PROMPT = "Reply with exactly WORKFLOW_CHILD_OK and nothing else." +SNAPSHOT_FINAL_TEXT = "ADVANCED_EXECUTABLE_OK" +SNAPSHOT_MOUNT_CODE = """\ +return (ctx) => { + harness.registerTool(ctx, harness.defineTool({ + name: 'snapshot_double', + description: 'Double a number for executable snapshot verification.', + parameters: { value: { type: 'number', required: true } }, + async execute(args) { + return [{ type: 'text', text: String(args.value * 2) }] + } + })) +} +""" +SNAPSHOT_WORKFLOW_SCRIPT = ( + "phase('Delegate')\n" + f"const reply = await agent('{SNAPSHOT_WORKFLOW_CHILD_PROMPT}', {{ label: 'workflow-child' }})\n" + "return { reply }" +) +SNAPSHOT_DIRECTORY = ( + Path(__file__).resolve().parent / "snapshots" / "python-sdk-single-exe" / "advanced" +) +SNAPSHOT_FILENAMES = ("result.json", "session.jsonl", "session.1.jsonl", "session.2.jsonl") +CUSTOM_CORDIS = """\ +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' +- id: agent-core + name: '@deepseek-ai/dsh-agent-core' + config: + tools: + mode: both +- 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 +- id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' +- id: subagents + name: '@deepseek-ai/dsh-subagent' +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn +- id: subagent-tool + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn +- id: workflow-engine + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn +- id: workflow-tool + name: '@deepseek-ai/dsh-tool-workflow' +- id: cordis-tool + name: '@deepseek-ai/dsh-tool-cordis' +""" + + +class MockModelHandler(BaseHTTPRequestHandler): + """Return deterministic text, worker, and orchestration completions.""" + + 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 = completion_chunks(body) + 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 + + +def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: + """Choose the next deterministic model response from request history.""" + messages = body.get("messages") + if not isinstance(messages, list) or not messages: + raise AssertionError(f"model request has no messages: {body}") + latest = messages[-1] + if not isinstance(latest, dict): + raise AssertionError(f"model request has an invalid latest message: {body}") + + if latest.get("role") == "tool": + call_id, tool_name = latest_tool_call(messages) + tool_text = message_text(latest.get("content")) + advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) + if advanced is not None: + return advanced + if "42" not in tool_text: + raise AssertionError(f"{tool_name} worker returned no expected value: {latest}") + if tool_name == "run_code": + return text_chunks(CODE_WORKER_TEXT) + if tool_name == "workflow": + return text_chunks(WORKFLOW_WORKER_TEXT) + raise AssertionError(f"unexpected tool follow-up: {tool_name}") + + prompt = message_text(latest.get("content")) + if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: + return text_chunks("DIRECT_CHILD_OK") + if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: + return text_chunks("WORKFLOW_CHILD_OK") + if prompt == SNAPSHOT_PROMPT: + assert_advertised_tool(body, "cordis_mount") + return tool_call_chunks( + "advanced-mount", + "cordis_mount", + {"code": SNAPSHOT_MOUNT_CODE}, + ) + if prompt == CODE_PROMPT: + assert_advertised_tool(body, "run_code") + return tool_call_chunks("call-code-worker", "run_code", {"code": "return 6 * 7"}) + if prompt == WORKFLOW_PROMPT: + assert_advertised_tool(body, "workflow") + return tool_call_chunks( + "call-workflow-worker", + "workflow", + { + "script": "return 6 * 7", + "meta": { + "name": "pkg-worker-smoke", + "description": "exercise the packaged workflow worker", + }, + }, + ) + return text_chunks(EXPECTED_TEXT) + + +def advanced_tool_followup( + body: dict[str, object], + call_id: str, + tool_name: str, + tool_text: str, +) -> list[dict[str, object]] | None: + """Advance the executable snapshot's deterministic parent tool chain.""" + if not call_id.startswith("advanced-"): + return None + if call_id == "advanced-mount" and tool_name == "cordis_mount": + if "mounted dyn-1" not in tool_text: + raise AssertionError(f"cordis_mount returned no mount id: {tool_text}") + assert_advertised_tool(body, "run_code") + assert_advertised_tool(body, "snapshot_double") + return tool_call_chunks( + "advanced-code", + "run_code", + {"code": "return await tools.snapshot_double({ value: 21 })"}, + ) + if call_id == "advanced-code" and tool_name == "run_code": + if "42" not in tool_text: + raise AssertionError(f"run_code returned no dynamic-tool value: {tool_text}") + assert_advertised_tool(body, "subagent") + return tool_call_chunks( + "advanced-direct-child", + "subagent", + { + "description": "Check direct child", + "prompt": SNAPSHOT_DIRECT_CHILD_PROMPT, + }, + ) + if call_id == "advanced-direct-child" and tool_name == "subagent": + if "DIRECT_CHILD_OK" not in tool_text: + raise AssertionError(f"subagent returned no expected child value: {tool_text}") + assert_advertised_tool(body, "workflow") + return tool_call_chunks( + "advanced-workflow", + "workflow", + { + "script": SNAPSHOT_WORKFLOW_SCRIPT, + "meta": { + "name": "advanced-exe-snapshot", + "description": "exercise one packaged workflow child", + }, + }, + ) + if call_id == "advanced-workflow" and tool_name == "workflow": + if "WORKFLOW_CHILD_OK" not in tool_text: + raise AssertionError(f"workflow returned no expected child value: {tool_text}") + assert_advertised_tool(body, "cordis_unmount") + return tool_call_chunks( + "advanced-unmount", + "cordis_unmount", + {"id": "dyn-1"}, + ) + if call_id == "advanced-unmount" and tool_name == "cordis_unmount": + if "unmounted dyn-1" not in tool_text: + raise AssertionError(f"cordis_unmount returned no disposal result: {tool_text}") + if "snapshot_double" in advertised_tool_names(body): + raise AssertionError("snapshot_double remained advertised after cordis_unmount") + return text_chunks(SNAPSHOT_FINAL_TEXT) + raise AssertionError(f"unexpected advanced tool follow-up: {call_id} {tool_name}: {tool_text}") + + +def text_chunks(text: str) -> list[dict[str, object]]: + """Build a complete streaming text response.""" + return [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + {"choices": [{"delta": {"content": text}}]}, + { + "choices": [{"delta": {"content": ""}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 3}, + }, + ] + + +def tool_call_chunks(call_id: str, name: str, arguments: dict[str, object]) -> list[dict[str, object]]: + """Build a complete streaming function-call response.""" + return [ + {"choices": [{"delta": {"role": "assistant", "content": None, "reasoning_content": ""}}]}, + { + "choices": [{ + "delta": { + "tool_calls": [{ + "index": 0, + "id": call_id, + "type": "function", + "function": {"name": name, "arguments": json.dumps(arguments)}, + }], + }, + }], + }, + { + "choices": [{"delta": {"content": ""}, "finish_reason": "tool_calls"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 3}, + }, + ] + + +def latest_tool_call(messages: list[object]) -> tuple[str, str]: + """Find the assistant call id and name paired with the latest tool result.""" + for message in reversed(messages[:-1]): + if not isinstance(message, dict): + continue + calls = message.get("tool_calls") + if not isinstance(calls, list): + continue + for call in reversed(calls): + if not isinstance(call, dict): + continue + function = call.get("function") + call_id = call.get("id") + if ( + isinstance(call_id, str) + and isinstance(function, dict) + and isinstance(function.get("name"), str) + ): + return call_id, function["name"] + raise AssertionError(f"tool result has no preceding assistant tool call: {messages}") + + +def message_text(content: object) -> str: + """Read OpenAI text content in either string or block-list form.""" + if isinstance(content, str): + return content + if isinstance(content, list): + return "".join( + block.get("text", "") + for block in content + if isinstance(block, dict) and isinstance(block.get("text"), str) + ) + return "" + + +def advertised_tool_names(body: dict[str, object]) -> set[str]: + """Return the model-facing tool names advertised on one request.""" + tools = body.get("tools") + if not isinstance(tools, list): + raise AssertionError(f"model request advertised no tools: {body}") + names: set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + continue + function = tool.get("function") + if isinstance(function, dict) and isinstance(function.get("name"), str): + names.add(function["name"]) + return names + + +def assert_advertised_tool(body: dict[str, object], expected: str) -> None: + """Require the packaged deployment to expose the requested tool.""" + names = advertised_tool_names(body) + if expected not in names: + raise AssertionError(f"model request did not advertise {expected}: {names}") + + +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", "sdk-snapshot", "direct"), + default="all", + ) + parser.add_argument("--exe", type=Path) + parser.add_argument("--update-snapshots", action="store_true") + args = parser.parse_args() + if args.scenario in {"all", "sdk-custom", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, snapshot, and direct scenarios") + if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: + parser.error("--update-snapshots requires --scenario sdk-snapshot or all") + 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", "sdk-snapshot"}: + assert args.exe is not None + smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) + 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, EXPECTED_TEXT) + + +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: + text_result = harness.run("reply with the smoke text", session_id="custom-smoke") + code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") + workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") + assert text_result.status == "ok", text_result + assert text_result.final_response == EXPECTED_TEXT, text_result.final_response + assert code_result.status == "ok", code_result + assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response + assert workflow_result.status == "ok", workflow_result + assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response + assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) + + +def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: + """Drive and compare the advanced SDK/executable behavioral snapshot.""" + from deepseek_harness import DeepSeekHarness + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-snapshot-") 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(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) + + assert result.status == "ok", result + assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response + methods = [notification.method for notification in result.notifications] + if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: + raise AssertionError(f"advanced snapshot emitted unexpected subagent lifecycle: {methods}") + if not any(event.get("type") == "tool/code-dispatch" for event in result.events): + raise AssertionError("advanced snapshot emitted no tool/code-dispatch event") + + logs = read_session_logs(sessions) + child_ids = snapshot_child_ids(result) + expected_ids = {SNAPSHOT_SESSION_ID, *child_ids} + if set(logs) != expected_ids: + raise AssertionError(f"advanced snapshot expected parent plus two child logs: {sorted(logs)}") + if "DIRECT_CHILD_OK" not in render_jsonl(logs[child_ids[0]]): + raise AssertionError("first advanced child log has no direct-subagent result") + if "WORKFLOW_CHILD_OK" not in render_jsonl(logs[child_ids[1]]): + raise AssertionError("second advanced child log has no workflow-subagent result") + + files = build_snapshot_files(result, logs, child_ids, root) + compare_snapshot_files(files, update_snapshots) + + +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, EXPECTED_TEXT) + + +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, *expected_texts: str) -> 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}") + rendered = "\n".join(lines) + for expected in expected_texts: + if expected not in rendered: + raise AssertionError(f"session log has no {expected!r} response: {logs[0]}") + + +def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: + """Parse every persisted JSONL session into a map keyed by header id.""" + logs: dict[str, list[dict[str, object]]] = {} + for path in sorted(sessions.rglob("*.jsonl")): + records = [ + json.loads(line) + for line in path.read_text(encoding="utf-8").splitlines() + if line + ] + if not records or records[0].get("type") != "session": + raise AssertionError(f"session log has no header: {path}") + session_id = records[0].get("id") + if not isinstance(session_id, str): + raise AssertionError(f"session log header has no string id: {path}") + if session_id in logs: + raise AssertionError(f"duplicate persisted session id: {session_id}") + logs[session_id] = records + return logs + + +def snapshot_child_ids(result: "TurnResult") -> list[str]: + """Return the two child session ids in their SDK notification order.""" + child_ids: list[str] = [] + for notification in result.notifications: + if notification.method != "subagent.started": + continue + payload = notification.payload + if payload.get("parentSessionId") != SNAPSHOT_SESSION_ID: + continue + child_id = payload.get("childSessionId") + if isinstance(child_id, str) and child_id not in child_ids: + child_ids.append(child_id) + if len(child_ids) != 2: + raise AssertionError(f"advanced snapshot expected two child session ids: {child_ids}") + return child_ids + + +def build_snapshot_files( + result: "TurnResult", + logs: dict[str, list[dict[str, object]]], + child_ids: list[str], + cwd: Path, +) -> dict[str, str]: + """Render the SDK result and three persisted logs into stable goldens.""" + replacements = [(str(cwd), "{{cwd}}"), (SNAPSHOT_SESSION_ID, "{{parent}}")] + for index, child_id in enumerate(child_ids, start=1): + replacements.append((child_id, f"{{{{child-{index}}}}}")) + agent_id = snapshot_agent_id(result, child_id) + replacements.append((agent_id, f"{{{{agent-{index}}}}}")) + replacements.sort(key=lambda pair: len(pair[0]), reverse=True) + + result_value = { + "session_id": result.session_id, + "status": result.status, + "final_response": result.final_response, + "events": result.events, + "notifications": [ + {"method": notification.method, "payload": notification.payload} + for notification in result.notifications + ], + "session_root": result.session_root, + } + normalized_result = normalize_snapshot_value(result_value, replacements) + files = { + "result.json": json.dumps(normalized_result, indent=2, ensure_ascii=False) + "\n", + "session.jsonl": render_jsonl( + [normalize_snapshot_value(record, replacements) for record in logs[SNAPSHOT_SESSION_ID]] + ), + } + for index, child_id in enumerate(child_ids, start=1): + files[f"session.{index}.jsonl"] = render_jsonl( + [normalize_snapshot_value(record, replacements) for record in logs[child_id]] + ) + if tuple(files) != SNAPSHOT_FILENAMES: + raise AssertionError(f"advanced snapshot file set drifted: {tuple(files)}") + return files + + +def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: + """Find the successful subagent id paired with one child session.""" + for notification in result.notifications: + if notification.method != "subagent.finished": + continue + payload = notification.payload + if payload.get("childSessionId") != child_id: + continue + if payload.get("provider") != "spawn" or payload.get("status") != "ok": + raise AssertionError(f"advanced child did not finish successfully: {payload}") + agent_id = payload.get("agentId") + if isinstance(agent_id, str): + return agent_id + raise AssertionError(f"advanced snapshot has no finished agent for child {child_id}") + + +def normalize_snapshot_value( + value: object, + replacements: list[tuple[str, str]], +) -> object: + """Scrub volatile values and bulky request headers without losing behavior.""" + if isinstance(value, str): + normalized = value + for actual, token in replacements: + normalized = normalized.replace(actual, token) + return normalized + if isinstance(value, list): + return [normalize_snapshot_value(item, replacements) for item in value] + if not isinstance(value, dict): + return value + + normalized = { + key: normalize_snapshot_value(item, replacements) + for key, item in value.items() + } + if normalized.get("type") == "session" and "createdAt" in normalized: + normalized["createdAt"] = 0 + if "seq" in normalized and "time" in normalized: + normalized["time"] = 0 + scrub_snapshot_header(normalized) + return normalized + + +def scrub_snapshot_header(value: dict[object, object]) -> None: + """Tokenize request-header bulk while retaining delta tool names.""" + data = value.get("data") + if not isinstance(data, dict): + return + if value.get("type") == "request/header": + header = data.get("header") + if not isinstance(header, dict): + return + if "system" in header: + header["system"] = "{{system}}" + tools = header.get("tools") + if isinstance(tools, list): + header["tools"] = [ + tool.get("name") if isinstance(tool, dict) else "{{tools}}" + for tool in tools + ] + if isinstance(header.get("messagePrefix"), list): + header["messagePrefix"] = ["{{messagePrefix}}" for _ in header["messagePrefix"]] + return + if value.get("type") != "request/header-delta": + return + system = data.get("system") + if isinstance(system, dict) and isinstance(system.get("insert"), list): + system["insert"] = ["{{system}}" for _ in system["insert"]] + tools = data.get("tools") + if isinstance(tools, dict): + for key in ("added", "changed"): + if isinstance(tools.get(key), list): + tools[key] = [scrub_snapshot_tool_schema(tool) for tool in tools[key]] + if isinstance(data.get("messagePrefix"), list): + data["messagePrefix"] = ["{{messagePrefix}}" for _ in data["messagePrefix"]] + + +def scrub_snapshot_tool_schema(value: object) -> object: + """Keep a changed tool's name while tokenizing its schema bulk.""" + if not isinstance(value, dict): + return value + return { + key: item if key == "name" else "{{tools}}" + for key, item in value.items() + } + + +def render_jsonl(records: list[object]) -> str: + """Render parsed JSON values as compact, newline-terminated JSONL.""" + return "".join( + json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n" + for record in records + ) + + +def compare_snapshot_files(files: dict[str, str], update: bool) -> None: + """Write or exactly compare the advanced executable snapshot files.""" + if update: + SNAPSHOT_DIRECTORY.mkdir(parents=True, exist_ok=True) + for name, content in files.items(): + (SNAPSHOT_DIRECTORY / name).write_text(content, encoding="utf-8") + print(f"smoke-python-runtime: updated snapshots in {SNAPSHOT_DIRECTORY}") + + existing = { + path.name + for path in SNAPSHOT_DIRECTORY.iterdir() + if path.is_file() + } if SNAPSHOT_DIRECTORY.is_dir() else set() + expected = set(SNAPSHOT_FILENAMES) + if existing != expected: + raise AssertionError( + "advanced snapshot files differ: " + f"missing={sorted(expected - existing)}, unexpected={sorted(existing - expected)}" + ) + for name, actual in files.items(): + expected_text = (SNAPSHOT_DIRECTORY / name).read_text(encoding="utf-8") + if actual == expected_text: + continue + diff = "".join(difflib.unified_diff( + expected_text.splitlines(keepends=True), + actual.splitlines(keepends=True), + fromfile=f"expected/{name}", + tofile=f"actual/{name}", + )) + raise AssertionError( + f"advanced executable snapshot mismatch in {name}; " + "rerun with --update-snapshots after reviewing the behavior\n" + f"{diff}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json new file mode 100644 index 0000000000..2ecfc76a2e --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -0,0 +1,2566 @@ +{ + "session_id": "{{parent}}", + "status": "ok", + "final_response": "ADVANCED_EXECUTABLE_OK", + "events": [ + { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + }, + { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + }, + { + "type": "step/start", + "seq": 2, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + }, + { + "type": "request/header", + "seq": 3, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] + }, + "reason": "initial" + } + }, + { + "type": "assistant/chunk", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-mount", + "name": "cordis_mount", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 4, + 5, + 6, + 7, + 8 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + }, + { + "type": "tool/result", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "mounted dyn-1 (plugin \"\", state: active)" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 10 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + }, + { + "type": "step/start", + "seq": 13, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + }, + { + "type": "request/header", + "seq": 14, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "workflow" + ] + }, + "reason": "fallback" + } + }, + { + "type": "assistant/chunk", + "seq": 15, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 16, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-code", + "name": "run_code", + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 17, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 18, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 19, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 20, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 15, + 16, + 17, + 18, + 19 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 21, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + }, + { + "type": "tool/code-dispatch", + "seq": 22, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + }, + "isError": false, + "resultSummary": "42" + } + }, + { + "type": "tool/result", + "seq": 23, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false, + "meta": { + "logs": [], + "dispatches": 1 + } + }, + "sourceEventSeqs": [ + 21 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 24, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + }, + { + "type": "step/start", + "seq": 25, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + }, + { + "type": "assistant/chunk", + "seq": 26, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 27, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-direct-child", + "name": "subagent", + "argumentsDelta": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 28, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 29, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 30, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 31, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 26, + 27, + 28, + 29, + 30 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 32, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + }, + { + "type": "tool/result", + "seq": 33, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 32 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 34, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + }, + { + "type": "step/start", + "seq": 35, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + }, + { + "type": "assistant/chunk", + "seq": 36, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 37, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-workflow", + "name": "workflow", + "argumentsDelta": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 38, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 39, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 40, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 41, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 36, + 37, + 38, + 39, + 40 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 42, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + }, + { + "type": "tool/result", + "seq": 43, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 42 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 44, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + }, + { + "type": "step/start", + "seq": 45, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + }, + { + "type": "assistant/chunk", + "seq": 46, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + }, + { + "type": "assistant/chunk", + "seq": 47, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-unmount", + "name": "cordis_unmount", + "argumentsDelta": "{\"id\": \"dyn-1\"}" + } + } + }, + { + "type": "assistant/chunk", + "seq": 48, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 49, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 50, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + }, + { + "type": "assistant/message", + "seq": 51, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 46, + 47, + 48, + 49, + 50 + ], + "surfaceOp": "append" + }, + { + "type": "tool/call", + "seq": 52, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + }, + { + "type": "tool/result", + "seq": 53, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "unmounted dyn-1 (plugin \"\")" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 52 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 54, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + }, + { + "type": "step/start", + "seq": 55, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + }, + { + "type": "request/header-delta", + "seq": 56, + "time": 0, + "data": { + "system": { + "keepStart": 62, + "keepEnd": 34, + "insert": [] + }, + "tools": { + "added": [], + "removed": [ + "snapshot_double" + ], + "changed": [] + } + } + }, + { + "type": "assistant/chunk", + "seq": 57, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + }, + { + "type": "assistant/chunk", + "seq": 58, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "ADVANCED_EXECUTABLE_OK" + } + } + }, + { + "type": "assistant/chunk", + "seq": 59, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 60, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + }, + { + "type": "assistant/chunk", + "seq": 61, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + }, + { + "type": "assistant/message", + "seq": 62, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 57, + 58, + 59, + 60, + 61 + ], + "surfaceOp": "append" + }, + { + "type": "step/end", + "seq": 63, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + }, + { + "type": "turn/end", + "seq": 64, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + ], + "notifications": [ + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 0, + "time": 0, + "data": { + "turn": 1, + "trigger": { + "kind": "message", + "source": { + "kind": "user" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "user/message", + "seq": 1, + "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + } + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 2, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 3, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "subagent", + "workflow" + ] + }, + "reason": "initial" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 5, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-mount", + "name": "cordis_mount", + "argumentsDelta": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 6, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 7, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 8, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 9, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "content": [ + { + "type": "tool-call", + "id": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 4, + 5, + 6, + 7, + 8 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 10, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "name": "cordis_mount", + "arguments": "{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 11, + "time": 0, + "data": { + "turn": 1, + "step": 1, + "callId": "advanced-mount", + "content": [ + { + "type": "text", + "text": "mounted dyn-1 (plugin \"\", state: active)" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 10 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 12, + "time": 0, + "data": { + "turn": 1, + "step": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 13, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 14, + "time": 0, + "data": { + "header": { + "config": { + "model": "smoke-model" + }, + "system": "{{system}}", + "tools": [ + "bash", + "bash_kill", + "bash_output", + "cordis_inspect", + "cordis_mount", + "cordis_unmount", + "run_code", + "skill", + "snapshot_double", + "subagent", + "workflow" + ] + }, + "reason": "fallback" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 15, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 16, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-code", + "name": "run_code", + "argumentsDelta": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 17, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 18, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 19, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 20, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "content": [ + { + "type": "tool-call", + "id": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 15, + 16, + 17, + 18, + 19 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 21, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "name": "run_code", + "arguments": "{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/code-dispatch", + "seq": 22, + "time": 0, + "data": { + "parentCallId": "advanced-code", + "subCallId": "advanced-code:code:1", + "name": "snapshot_double", + "arguments": { + "value": 21 + }, + "isError": false, + "resultSummary": "42" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 23, + "time": 0, + "data": { + "turn": 1, + "step": 2, + "callId": "advanced-code", + "content": [ + { + "type": "text", + "text": "42" + } + ], + "isError": false, + "meta": { + "logs": [], + "dispatches": 1 + } + }, + "sourceEventSeqs": [ + 21 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 24, + "time": 0, + "data": { + "turn": 1, + "step": 2 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 25, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 26, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 27, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-direct-child", + "name": "subagent", + "argumentsDelta": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 28, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 29, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 30, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 31, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "content": [ + { + "type": "tool-call", + "id": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 26, + 27, + 28, + 29, + 30 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 32, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "name": "subagent", + "arguments": "{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}" + } + } + } + }, + { + "method": "subagent.started", + "payload": { + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-1}}" + } + }, + { + "method": "subagent.finished", + "payload": { + "provider": "spawn", + "agentId": "{{agent-1}}", + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-1}}", + "status": "ok", + "stopReason": "completed", + "lastAssistantMessage": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ] + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 33, + "time": 0, + "data": { + "turn": 1, + "step": 3, + "callId": "advanced-direct-child", + "content": [ + { + "type": "text", + "text": "DIRECT_CHILD_OK" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 32 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 34, + "time": 0, + "data": { + "turn": 1, + "step": 3 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 35, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 36, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 37, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-workflow", + "name": "workflow", + "argumentsDelta": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 38, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 39, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 40, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 41, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "content": [ + { + "type": "tool-call", + "id": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 36, + 37, + 38, + 39, + 40 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 42, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "name": "workflow", + "arguments": "{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}" + } + } + } + }, + { + "method": "subagent.started", + "payload": { + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-2}}" + } + }, + { + "method": "subagent.finished", + "payload": { + "provider": "spawn", + "agentId": "{{agent-2}}", + "parentSessionId": "{{parent}}", + "childSessionId": "{{child-2}}", + "status": "ok", + "stopReason": "completed", + "lastAssistantMessage": [ + { + "type": "text", + "text": "WORKFLOW_CHILD_OK" + } + ] + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 43, + "time": 0, + "data": { + "turn": 1, + "step": 4, + "callId": "advanced-workflow", + "content": [ + { + "type": "text", + "text": "workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 42 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 44, + "time": 0, + "data": { + "turn": 1, + "step": 4 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 45, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 46, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "tool-call" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 47, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "tool-call-delta", + "index": 0, + "id": "advanced-unmount", + "name": "cordis_unmount", + "argumentsDelta": "{\"id\": \"dyn-1\"}" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 48, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 49, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 50, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "chunk": { + "type": "finish", + "reason": { + "kind": "tool-calls" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 51, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "content": [ + { + "type": "tool-call", + "id": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 46, + 47, + 48, + 49, + 50 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/call", + "seq": 52, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "name": "cordis_unmount", + "arguments": "{\"id\": \"dyn-1\"}" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "tool/result", + "seq": 53, + "time": 0, + "data": { + "turn": 1, + "step": 5, + "callId": "advanced-unmount", + "content": [ + { + "type": "text", + "text": "unmounted dyn-1 (plugin \"\")" + } + ], + "isError": false + }, + "sourceEventSeqs": [ + 52 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 54, + "time": 0, + "data": { + "turn": 1, + "step": 5 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/start", + "seq": 55, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header-delta", + "seq": 56, + "time": 0, + "data": { + "system": { + "keepStart": 62, + "keepEnd": 34, + "insert": [] + }, + "tools": { + "added": [], + "removed": [ + "snapshot_double" + ], + "changed": [] + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 57, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-start", + "index": 0, + "blockType": "text" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 58, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "text-delta", + "index": 0, + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 59, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "block-end", + "index": 0, + "block": { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 60, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "usage", + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/chunk", + "seq": 61, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "chunk": { + "type": "finish", + "reason": { + "kind": "stop" + } + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "assistant/message", + "seq": 62, + "time": 0, + "data": { + "turn": 1, + "step": 6, + "content": [ + { + "type": "text", + "text": "ADVANCED_EXECUTABLE_OK" + } + ], + "usage": { + "inputTokens": 3, + "outputTokens": 3 + } + }, + "sourceEventSeqs": [ + 57, + 58, + 59, + 60, + 61 + ], + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "step/end", + "seq": 63, + "time": 0, + "data": { + "turn": 1, + "step": 6 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/end", + "seq": 64, + "time": 0, + "data": { + "turn": 1, + "reason": { + "kind": "completed" + } + } + } + } + }, + { + "method": "session.finished", + "payload": { + "sessionId": "{{parent}}", + "status": "ok", + "reason": { + "kind": "completed" + } + } + } + ], + "session_root": "{{cwd}}/sessions" +} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl new file mode 100644 index 0000000000..beb80d3c85 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl new file mode 100644 index 0000000000..948c835148 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl new file mode 100644 index 0000000000..bb0ee1d4d0 --- /dev/null +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -0,0 +1,66 @@ +{"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","subagent","workflow"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n async execute(args) {\\n return [{ type: 'text', text: String(args.value * 2) }]\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":14,"time":0,"data":{"header":{"config":{"model":"smoke-model"},"system":"{{system}}","tools":["bash","bash_kill","bash_output","cordis_inspect","cordis_mount","cordis_unmount","run_code","skill","snapshot_double","subagent","workflow"]},"reason":"fallback"}} +{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}} +{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}}}} +{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":20,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[15,16,17,18,19],"surfaceOp":"append"} +{"type":"tool/call","seq":21,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\"}"}} +{"type":"tool/code-dispatch","seq":22,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"resultSummary":"42"}} +{"type":"tool/result","seq":23,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false,"meta":{"logs":[],"dispatches":1}},"sourceEventSeqs":[21],"surfaceOp":"append"} +{"type":"step/end","seq":24,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":25,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":31,"time":0,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} +{"type":"tool/call","seq":32,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":33,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[32],"surfaceOp":"append"} +{"type":"step/end","seq":34,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":35,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":41,"time":0,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[36,37,38,39,40],"surfaceOp":"append"} +{"type":"tool/call","seq":42,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":43,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[42],"surfaceOp":"append"} +{"type":"step/end","seq":44,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":45,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":47,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":51,"time":0,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[46,47,48,49,50],"surfaceOp":"append"} +{"type":"tool/call","seq":52,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"\")"}],"isError":false},"sourceEventSeqs":[52],"surfaceOp":"append"} +{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header-delta","seq":56,"time":0,"data":{"system":{"keepStart":62,"keepEnd":34,"insert":[]},"tools":{"added":[],"removed":["snapshot_double"],"changed":[]}}} +{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":62,"time":0,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[57,58,59,60,61],"surfaceOp":"append"} +{"type":"step/end","seq":63,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":64,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json index a92381502a..b03bd4d619 100644 --- a/scripts/translation-pairing.manifest.json +++ b/scripts/translation-pairing.manifest.json @@ -4,7 +4,11 @@ "docs/development.md", "docs/i18n/README.md", "docs/i18n/translation-rules.md", - "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md" + "docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md", + "docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md", + "python/README.md", + "python/sdk-runtime/README.md", + "python/sdk/README.md" ], "excluded": [ "docs/AGENTS.md", @@ -13,6 +17,7 @@ "docs/tool-catalog.md", "docs/persistence-catalog.md", "docs/cordis-catalog/", - "docs/i18n/terminology.md" + "docs/i18n/terminology.md", + "python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/" ] } diff --git a/scripts/verify-runtime-closure.ts b/scripts/verify-runtime-closure.ts new file mode 100644 index 0000000000..31d2b9e3bf --- /dev/null +++ b/scripts/verify-runtime-closure.ts @@ -0,0 +1,116 @@ +/** + * Verify that the Python single-exe deploy manifest explicitly supplies every + * required workspace peer of every workspace package in its dependency graph. + * + * `pnpm deploy --config.auto-install-peers=false` cannot repair an incomplete + * runtime root. Keeping the peer at the root also prevents a successful build + * from producing an executable that fails only when Cordis loads the plugin. + */ +import { readFile, readdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' + +interface PackageManifest { + name?: string + dependencies?: Record + optionalDependencies?: Record + peerDependencies?: Record + peerDependenciesMeta?: Record +} + +interface WorkspacePackage { + path: string + manifest: PackageManifest +} + +const root = resolve(import.meta.dirname, '..') +const { values } = parseArgs({ + args: process.argv.slice(2), + options: { manifest: { type: 'string' } }, +}) +const runtimeManifestPath = resolve(root, values.manifest ?? 'python/sdk-runtime/package.json') +const runtimeManifest = await loadManifest(runtimeManifestPath) +const runtimeName = runtimeManifest.name ?? 'python/sdk-runtime' +const workspace = await loadWorkspacePackages() +const runtimeDependencies = runtimeManifest.dependencies ?? {} +const parents = new Map() +const queue: string[] = [] + +for (const dependency of Object.keys(runtimeDependencies).sort()) { + if (!workspace.has(dependency)) continue + parents.set(dependency, undefined) + queue.push(dependency) +} + +const failures: string[] = [] +for (let index = 0; index < queue.length; index += 1) { + const packageName = queue[index] + if (packageName === undefined) continue + const current = workspace.get(packageName) + if (current === undefined) continue + const peers = current.manifest.peerDependencies ?? {} + const peerMeta = current.manifest.peerDependenciesMeta ?? {} + for (const peer of Object.keys(peers).sort()) { + if (!workspace.has(peer) || peerMeta[peer]?.optional === true) continue + if (runtimeDependencies[peer]?.startsWith('workspace:') === true) continue + failures.push(`${formatChain(runtimeName, packageName, parents)} -> ${peer}`) + } + const dependencies = { + ...current.manifest.dependencies, + ...current.manifest.optionalDependencies, + } + for (const dependency of Object.keys(dependencies).sort()) { + if (!workspace.has(dependency) || parents.has(dependency)) continue + parents.set(dependency, packageName) + queue.push(dependency) + } +} + +if (failures.length > 0) { + console.error('verify-runtime-closure: required workspace peers are missing from python/sdk-runtime dependencies:') + for (const failure of failures) console.error(` ${failure}`) + process.exit(1) +} + +console.log(`verify-runtime-closure: ${queue.length} workspace packages form a closed runtime dependency graph.`) + +async function loadWorkspacePackages(): Promise> { + const paths: string[] = [] + for (const group of await childDirectories(join(root, 'packages'))) { + for (const packageDir of await childDirectories(join(root, 'packages', group))) { + paths.push(join(root, 'packages', group, packageDir, 'package.json')) + } + } + for (const packageDir of await childDirectories(join(root, 'vendor'))) { + paths.push(join(root, 'vendor', packageDir, 'package.json')) + } + const result = new Map() + for (const path of paths) { + const manifest = await loadManifest(path) + if (manifest.name !== undefined) result.set(manifest.name, { path, manifest }) + } + return result +} + +async function childDirectories(path: string): Promise { + const entries = await readdir(path, { withFileTypes: true }) + return entries.filter(entry => entry.isDirectory()).map(entry => entry.name).sort() +} + +async function loadManifest(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as PackageManifest +} + +function formatChain( + runtimeName: string, + packageName: string, + parents: ReadonlyMap, +): string { + const chain = [packageName] + let parent = parents.get(packageName) + while (parent !== undefined) { + chain.unshift(parent) + parent = parents.get(parent) + } + return [runtimeName, ...chain].join(' -> ') +} diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts index 3d80572e7c..b27fcfe885 100644 --- a/scripts/verify-translation-pairing.ts +++ b/scripts/verify-translation-pairing.ts @@ -55,8 +55,8 @@ const root = resolve(import.meta.dirname, '..') const listMode = process.argv.includes('--list') const writeMode = process.argv.includes('--write') -/** Scope of the bilingual contract: the root README and the docs tree. */ -const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml'] +/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml'] /** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ interface Manifest { diff --git a/tsconfig.build.json b/tsconfig.build.json index 9c236fe02a..b3d904aa9b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -56,6 +56,8 @@ { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/app-boot" }, + { "path": "./packages/ui/jsonrpc" }, + { "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, diff --git a/tsconfig.json b/tsconfig.json index 2ab1a5da30..a52f21a86e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -67,6 +67,8 @@ { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/app-boot" }, + { "path": "./packages/ui/jsonrpc" }, + { "path": "./packages/ui/jsonrpc-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" },