mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into codex/trim-ai-prose
# Conflicts: # docs/config-catalog.md # docs/event-producer-consumer.md # docs/rfc/implemented/feature/2026-07-05-dynamic-workflows.md # examples/acp-agent/tests/acp.snapshot.ts # packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts # packages/code-runtime/code-runtime-worker/tsdown.config.ts
This commit is contained in:
@@ -12,7 +12,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
|
||||
- [AGENTS.md](../../../AGENTS.md) and [packages/AGENTS.md](../../../packages/AGENTS.md): repository and package rules.
|
||||
- [docs/defensive-patterns.md](../../../docs/defensive-patterns.md): subprocess, callback, async-state, and disposal bug classes.
|
||||
- [docs/AGENTS.md](../../../docs/AGENTS.md): documentation placement and prose discipline.
|
||||
- [dsh-trim-prose](../dsh-trim-prose/SKILL.md): editorial judgment for comments, docs, prompts, and visible strings.
|
||||
- [dsh-prose-standard](../dsh-prose-standard/SKILL.md): required coverage and editorial judgment for comments, docs, prompts, and visible strings.
|
||||
- [docs/testing.md](../../../docs/testing.md) and the [quality-gates RFC](../../../docs/rfc/implemented/process/2026-06-11-quality-gates.md): required test tiers and gates.
|
||||
- [RFC index](../../../docs/rfc/README.md): design rationale. Treat disagreement with an RFC as a design discussion, not an automatic veto.
|
||||
- For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md), [terminology.md](../../../docs/i18n/terminology.md), and [dsh-translate-docs](../dsh-translate-docs/SKILL.md).
|
||||
|
||||
@@ -5,7 +5,7 @@ description: 'Use when writing, moving, reviewing, or auditing documentation in
|
||||
|
||||
# Applying the DeepSeek Harness Documentation Standard
|
||||
|
||||
The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-trim-prose](../dsh-trim-prose/SKILL.md) for editorial judgment and never treat length alone as a defect.
|
||||
The contract lives in [docs/AGENTS.md](../../../docs/AGENTS.md). This workflow covers placement, corpus audits, budgets, and validation across Markdown, JSDoc, and code comments. It is guidance, not a script; use [dsh-prose-standard](../dsh-prose-standard/SKILL.md) for required coverage and editorial judgment, and never treat length alone as a defect.
|
||||
|
||||
## Sources of truth (read, don't re-summarize)
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
---
|
||||
name: dsh-trim-prose
|
||||
description: Use when trimming, restoring, or auditing prose in the deepseek-harness repo, including Markdown, JSDoc, code and test comments, prompts, descriptions, diagnostics, and CLI or UI strings; especially for generated-sounding narration, duplicated explanation, or an earlier edit that may have removed contract detail.
|
||||
name: dsh-prose-standard
|
||||
description: Use when writing, reviewing, restoring, trimming, or auditing prose in the deepseek-harness repo, including deciding where documentation or comments are required across Markdown, JSDoc, code and test comments, prompts, descriptions, diagnostics, and CLI or UI strings.
|
||||
---
|
||||
|
||||
# Trim DeepSeek Harness Prose
|
||||
# DeepSeek Harness Prose Standard
|
||||
|
||||
Preserve the contract while removing reasoning transcripts, repetition, and decoration. This skill owns editorial judgment; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script.
|
||||
Write enough to preserve the contract, then remove reasoning transcripts, repetition, and decoration. This skill owns editorial judgment and required prose coverage; use [dsh-doc-standards](../dsh-doc-standards/SKILL.md) for placement, budgets, bilingual pairs, and documentation gates. It is guidance, not a script.
|
||||
|
||||
## Inputs and exclusions
|
||||
|
||||
@@ -33,20 +33,22 @@ Keep a complete local contract at the point of use: behavior, failure, ownership
|
||||
|
||||
Keep non-obvious rationale when omitting it could plausibly cause misuse or an incorrect simplification. Otherwise state the consequence and link the rationale home.
|
||||
|
||||
## Calibrate by prose surface
|
||||
## Required coverage by prose surface
|
||||
|
||||
- **Public JSDoc:** retain caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability.
|
||||
- **Internal comments:** retain orientation for non-local structure and obviously complicated local structure. Delete control-flow narration and code restatement.
|
||||
- **Module comments:** retain the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation.
|
||||
- **Tests:** retain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories.
|
||||
- **Cookbooks:** retain prerequisites, required actions, the real entry path, observable verification, and concise warnings.
|
||||
- **READMEs:** retain the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Link algorithms and design rationale.
|
||||
- **RFCs:** presume unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps are load-bearing. Implemented RFCs state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision.
|
||||
This is not a one-way shortening pass. Add or restore prose when code, types, and structure do not communicate a required contract below. Do not add a comment when those facts are already obvious locally.
|
||||
|
||||
- **Public JSDoc:** document caller-visible return distinctions, throws or rejections, side effects, ownership, timing, cancellation, and durability.
|
||||
- **Internal comments:** orient non-local structure and obviously complicated local structure, including invariants, race ordering, ownership, security boundaries, and surprising failure behavior. Delete control-flow narration and code restatement.
|
||||
- **Module comments:** state the module's role, boundaries, and non-obvious architecture choices; link architecture choices to their owning explanation.
|
||||
- **Tests:** explain only non-obvious test design—why a fixture, assertion, platform accommodation, real entry path, or indirect observation is necessary. Delete walkthroughs and inventories.
|
||||
- **Cookbooks:** include prerequisites, required actions, the real entry path, observable verification, and concise warnings.
|
||||
- **READMEs:** include the consumer contract: configuration, semantics, failures, limitations, extension points, and model-visible effects. Link algorithms and design rationale.
|
||||
- **RFCs:** retain unique rationale, mechanisms, alternatives, consequences, shipped verification contracts, and named coverage gaps. Implemented RFCs state shipped reality in the present tense; remove planning checklists, not evidence of what pins the decision.
|
||||
- **Postmortems:** retain the incident sequence, evidence, causal chain, impact, and prevention. Remove repeated persuasion or implementation detail that does not establish causality.
|
||||
- **Skills and agent instructions:** preserve behavioral guardrails and explicit scope statements such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
|
||||
- **Examples and configuration comments:** retain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
|
||||
- **Skills and agent instructions:** state behavioral guardrails and explicit scope limitations such as “guidance, not a script/checklist.” Keep the workflow concise and link its source of truth.
|
||||
- **Examples and configuration comments:** explain boundaries, non-obvious wiring or load order, security stance, replay behavior, exceptions, and likely misuse. Do not narrate entries that the configuration already shows.
|
||||
- **Prompts and visible strings:** treat wording as behavior. Inspect generated output and run behavior validation or state why no snapshot applies.
|
||||
- **Diagnostics:** retain the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
|
||||
- **Diagnostics:** name the failing subject or path, violated rule, and correction when it is non-obvious. Remove internal execution narration.
|
||||
|
||||
Preserve searchable mechanism names and meaningful modal, temporal, or negative emphasis. Normalize decorative emphasis only.
|
||||
|
||||
@@ -55,7 +57,7 @@ Preserve searchable mechanism names and meaningful modal, temporal, or negative
|
||||
1. Confirm the scope, mode, current branch or PR base, and applicable `AGENTS.md` files. Do not inspect unrelated branches.
|
||||
2. Read [the documentation standard](../../../docs/AGENTS.md) and the owning code or document before judging a passage. For calibration or unfamiliar cases, read [the distilled examples](references/examples.md).
|
||||
3. Inspect the requested scope, not only the largest files. Use searches and word counts to find candidates, then judge passages semantically.
|
||||
4. Classify each candidate as keep, trim, restore, restructure, or defer. Apply clear changes; do not manufacture edits to satisfy a deletion target.
|
||||
4. Classify each candidate as keep, add, trim, restore, restructure, or defer. Apply clear changes; do not manufacture edits to satisfy a deletion target.
|
||||
5. Update the owner before derivative artifacts. Re-check analogous passages after learning a new rule.
|
||||
6. Run the narrow relevant checks, documentation gates, `git diff --check`, and behavior tests for visible strings. Verify the final diff contains no `vendor/` path and report any accidental vendor match rather than claiming a clean exclusion history.
|
||||
7. Report the inspected scope, clear changes, deliberate keeps, deferred cases, and checks actually run.
|
||||
4
.agents/skills/dsh-prose-standard/agents/openai.yaml
Normal file
4
.agents/skills/dsh-prose-standard/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "DSH Prose Standard"
|
||||
short_description: "Write concise prose without losing contracts"
|
||||
default_prompt: "Use $dsh-prose-standard to audit a specified repository scope for required, complete, and concise prose."
|
||||
@@ -42,6 +42,16 @@ Keep the test tiers, required action, real entry path, and observable verificati
|
||||
|
||||
The actor, ordering, ownership boundary, and completion guarantee are separate factual clauses.
|
||||
|
||||
## Event JSDoc preserves boundary timing
|
||||
|
||||
**Over-trimmed:** “Composes and caches the session prefix.”
|
||||
|
||||
**Balanced:** “Composes the session prefix once before the first pre-step and request boundary. Listener appends join the current request, and pre-step pressure accounting receives the composed prefix.”
|
||||
|
||||
**Over-detailed:** A walkthrough of the loop helpers, cache fields, and promise callbacks that implement the ordering.
|
||||
|
||||
Event order and its current-request consequence are caller-visible behavior, not implementation narration.
|
||||
|
||||
## Orient complicated code without narrating it
|
||||
|
||||
**Over-trimmed:** “Worker realm support.”
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Trim DSH Prose"
|
||||
short_description: "Balance concise prose with complete contracts"
|
||||
default_prompt: "Use $dsh-trim-prose to audit a specified repository scope and trim or restore prose without losing contract details."
|
||||
253
.github/workflows/build-exe-for-python-sdk.yml
vendored
Normal file
253
.github/workflows/build-exe-for-python-sdk.yml
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
name: Build single-exe
|
||||
|
||||
# Native builds for the release targets; see
|
||||
# docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md.
|
||||
# The retained artifacts are one SDK wheel and three runtime wheels; bare
|
||||
# executables and source closures are test inputs. Run manually or label a PR
|
||||
# `build-exe` (remove and reapply to rerun). Checkout uses the triggering ref,
|
||||
# so dispatch needs no separate ref input.
|
||||
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]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Job-level conditions cannot inspect `matrix`, so validate target names and
|
||||
# construct the matrix before the dependent jobs.
|
||||
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:
|
||||
# Label runs and blank dispatch inputs build all 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-only: hosted arm64 Linux uses ubuntu-24.04-arm, while
|
||||
# macos-latest is Apple Silicon.
|
||||
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"
|
||||
|
||||
# Linux architectures share runner.os, so the cache key includes arch.
|
||||
- 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-
|
||||
|
||||
# Cache pkg's patched Node binary per target; lockfile changes roll the
|
||||
# exact key while the restore prefix can seed its replacement.
|
||||
- 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
|
||||
|
||||
- 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
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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/
|
||||
|
||||
123
.gitlab-ci.yml
Normal file
123
.gitlab-ci.yml
Normal file
@@ -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; }
|
||||
@@ -25,7 +25,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, 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)
|
||||
@@ -117,7 +117,7 @@ Read [docs/defensive-patterns.md](docs/defensive-patterns.md) before lifecycle,
|
||||
|
||||
Everything compiles under `strict: true` with `noImplicitAny`; every remaining `any` explains why a narrower type is infeasible. Every module and export has concise JSDoc for its non-obvious contract; function-like exports include `@param`/`@returns`, as enforced by `verify-export-jsdoc`. Heritage-declared members, plugin-protocol slots, and constructors keep their docs at the declaring seam, protocol, or class.
|
||||
|
||||
Comments and docs preserve complete contracts and non-obvious orientation, not the author's reasoning process. Do not narrate control flow, walk through tests, preserve review history, or restate code. Keep every factual clause that affects behavior, failure, timing, ownership, or safe use; link aggressively to the owning rationale instead of duplicating it. Use [dsh-trim-prose](.agents/skills/dsh-trim-prose/SKILL.md) for editorial judgment. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally.
|
||||
Comments and docs preserve complete contracts and non-obvious orientation, not the author's reasoning process. Do not narrate control flow, walk through tests, preserve review history, or restate code. Keep every factual clause that affects behavior, failure, timing, ownership, or safe use; link aggressively to the owning rationale instead of duplicating it. Use [dsh-prose-standard](.agents/skills/dsh-prose-standard/SKILL.md) for prose decisions. Encode enforceable invariants in checks, using narrow justified escape hatches rather than disabling a rule globally.
|
||||
|
||||
Docs are part of every change: code changes update their README and JSDoc in the SAME change; a bilingual-pair edit updates the counterpart and re-records ([i18n contract](docs/i18n/README.md)). The writing rules — document the current state never the history, one physical line per paragraph, one home per fact — and the word-budget gate live in [docs/AGENTS.md](docs/AGENTS.md).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md — The documentation standard
|
||||
|
||||
This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for editorial judgment; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
|
||||
This file defines Markdown tiers, writing rules, and `verify-doc-budgets` ceilings. Use [dsh-doc-standards](../.agents/skills/dsh-doc-standards/SKILL.md) for placement and validation, and [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment; the [doc-tiers RFC](rfc/implemented/process/2026-07-04-doc-tiers-and-budgets.md) owns rationale.
|
||||
|
||||
## The tier taxonomy: one home per fact
|
||||
|
||||
@@ -31,7 +31,7 @@ Placement test: bug story → postmortem; design rationale → RFC; procedure
|
||||
- **Every new event's JSDoc carries an `@mode` tag** (emit | waterfall | parallel | serial); the catalog generator hard-errors without it. Write the JSDoc to stand alone — it becomes the catalog entry ([catalog RFC](rfc/implemented/process/2026-06-20-generated-cordis-catalog.md)).
|
||||
- **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)).
|
||||
- **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)).
|
||||
- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for the full decision rules and examples.
|
||||
- **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples.
|
||||
- Your audience is professional programmers. Prefer concise and straight-forward English over metaphor. Do not overuse words like "gate", "vocabulary", "surface", "seams".
|
||||
|
||||
## Wordcount Budgets
|
||||
|
||||
@@ -203,7 +203,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:23`](../packages/code-runtime/code-runtime-worker/src/index.ts)
|
||||
Source: [`packages/code-runtime/code-runtime-worker/src/index.ts:24`](../packages/code-runtime/code-runtime-worker/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-compact-basic`
|
||||
|
||||
@@ -319,6 +319,26 @@ export interface Config {
|
||||
|
||||
Source: [`packages/hooks/hooks-codex/src/index.ts:34`](../packages/hooks/hooks-codex/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-jsonrpc`
|
||||
|
||||
Requires: `agents`
|
||||
|
||||
```ts config-catalog
|
||||
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
|
||||
export interface JsonRpcConfig {
|
||||
/** Transport input override; production uses `process.stdin`. */
|
||||
input?: Readable
|
||||
/** Transport output override; production uses `process.stdout`. */
|
||||
output?: Writable
|
||||
/** Process-exit override; production uses `process.exit`. */
|
||||
exit?: (code: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
Depends on: `Readable` (`node:stream`) · `Writable` (`node:stream`)
|
||||
|
||||
Source: [`packages/ui/jsonrpc/src/index.ts:26`](../packages/ui/jsonrpc/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-llm-deepseek`
|
||||
|
||||
Requires: `llm`
|
||||
@@ -1095,6 +1115,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))
|
||||
|
||||
@@ -18,7 +18,7 @@ packages/<group>/<pkg>/
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:278`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:280`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/pre-step` — serial
|
||||
|
||||
@@ -99,7 +99,7 @@ Source: [`packages/core/agent/src/types.ts:222`](../../packages/core/agent/src/t
|
||||
|
||||
### `agent/session-prefix` — waterfall
|
||||
|
||||
Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
|
||||
```ts cordis-catalog
|
||||
'agent/session-prefix'(this: Scoped<Agent>, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise<Message[]>): Promise<Message[]>
|
||||
@@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:235`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:237`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/session-start` — emit
|
||||
|
||||
@@ -143,7 +143,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-continuation` — waterfall
|
||||
|
||||
@@ -155,7 +155,7 @@ Override whether the turn continues. The default continues after tool calls or s
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:256`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:258`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
### `agent/turn-stop` — serial
|
||||
|
||||
@@ -167,7 +167,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded. A
|
||||
|
||||
Types: [Agent](../core-data-structures/core.md)
|
||||
|
||||
Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts)
|
||||
Source: [`packages/core/agent/src/types.ts:267`](../../packages/core/agent/src/types.ts)
|
||||
|
||||
## `approval/*`
|
||||
|
||||
|
||||
@@ -7,31 +7,31 @@ 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:139`](../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:139`](../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:147`](../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:278`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:280`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:200`](../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) |
|
||||
| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:210`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) |
|
||||
| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:222`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:235`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:237`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:179`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:156`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:246`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:256`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:265`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - |
|
||||
| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:258`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) |
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:267`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../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:51`](../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:46`](../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:46`](../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:55`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:66`](../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:66`](../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:75`](../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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## 范围、排除与推进
|
||||
|
||||
**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。
|
||||
**范围**:根 `README.md`、`docs/**` 下的全部内容,以及 `python/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。
|
||||
|
||||
**排除**(永不配对,门禁拒绝为它们建 `.zh.md` 或 `.i18n.yaml`):
|
||||
|
||||
|
||||
@@ -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 | 包装层 | |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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-<platform>-<arch>` 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-<platform>-<arch>` (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).
|
||||
@@ -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-<platform>-<arch>` 写入 `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-<platform>-<arch>`(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 每个平台一个任务相匹配,本地多平台构建串行执行)。
|
||||
@@ -26,7 +26,9 @@ One deliberate strictness DIVERGENCE from CC: hook misuse — unknown or deferre
|
||||
|
||||
**Why `node:worker_threads`**: each run gets one unpooled worker. A vm context limits the documented script surface, while message-port RPC bridges `agent()` to host-side child loops. The worker prevents synchronous script work from blocking the host, provides a serialization boundary, and permits forced termination after cancellation. `isolated-vm` was rejected because of its maintenance state and deployment requirements.
|
||||
|
||||
The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol, and host-owned records preserve the subagent run contract across it. The [agent-scope runtime-design RFC](../architecture/2026-07-12-agent-scope-runtime-design.md#workflow-children-are-pending-starts-or-published-records) owns the start, cancellation, worker-death, result-precedence, and disposal algorithms.
|
||||
The host validates metadata and parses the body before publication. Private enum-keyed payload maps define the wire protocol; pending starts, published child records, one cancellation signal, worker-death reaping, result precedence, and disposal quiescence preserve the subagent run contract across it. 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. The built-bin gate separately runs the `"./worker"` export's `lib/worker.cjs` entry under plain Node.
|
||||
|
||||
**Meta is data**: the schema-validated `meta` field reaches the seam as JSON and is only shape-validated. The host never evaluates a metadata literal, which would let script-controlled accessors run outside the worker's isolation.
|
||||
|
||||
@@ -46,7 +48,7 @@ An output schema makes a schema-valid committed capture mandatory for successful
|
||||
|
||||
## Testing
|
||||
|
||||
Worker-side logic runs through an in-process `MessageChannel` so V8 coverage measures it. Unit tests cover script helpers, fatal and nullable failures, JSON boundaries, caps, cancellation, child ownership, and structured output through real loops. A built-lib smoke runs the separately bundled `lib/worker.js` under plain Node, a with-key e2e drives real child agents, and model-facing workflow behavior is snapshot-covered through its owning example.
|
||||
Worker-side logic runs through an in-process `MessageChannel` so V8 coverage measures it. Unit tests cover script helpers, fatal and nullable failures, JSON boundaries, caps, cancellation, child ownership, and structured output through real loops. A built-lib smoke runs the separately bundled `lib/worker.cjs` under plain Node, a with-key e2e drives real child agents, and model-facing workflow behavior is snapshot-covered through its owning example.
|
||||
|
||||
## Deferred (documented non-goals of this cut)
|
||||
|
||||
|
||||
@@ -20,6 +20,10 @@ Three properties carry the design:
|
||||
|
||||
Because composition runs before the boundary snapshot, a composing listener's session append joins the CURRENT request's derived history. Compaction structurally cannot touch the prefix (or the system prompt): it rewrites surface nodes, and header state never enters the surface.
|
||||
|
||||
## Testing
|
||||
|
||||
[Interception tests](../../../../packages/core/agent-loop/tests/interception.spec.ts) pin compose-once reuse with no header deltas, prepend order, empty-prefix omission, immutability, and composition before pre-step; [cancellation tests](../../../../packages/core/agent-loop/tests/cancel.spec.ts) pin discard and recomposition. Session codec, invariant, and compaction tests cover header round trips, request reconstruction, and prefix-aware pressure accounting. Snapshot normalization preserves prefix counts, while the [pinned-header scenario](../testing/2026-07-06-pin-request-header-content-in-one-scenario.md) owns content and the default example remains prefix-free. No prefix-specific e2e is needed because the seam is deterministic and provider-independent; the with-key [request-cache e2e](../../../../packages/core/agent-loop/tests/request-cache.e2e.ts) covers its cache economics.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Per-request `before`/`after` slots recomputed every step** (the shape first proposed: a waterfall firing on every request, contributing frozen `before` messages ahead of the history and fresh `after` messages behind it) — rejected. A per-step `before` recompose invites silent drift — nothing anchors it to the log short of logging a header delta per step — and an `after` slot sits behind the growing history, so its tokens re-pay on every request and everything after it is uncacheable. Measured against the alternatives, every current update pattern is served cheaper by a durable append (paid once, cache-read thereafter), and the only content with no home was the session-stable opener — which wants freezing, not recomputation.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,6 +17,6 @@ Temp-cwd keyless smokes set `TSX_TSCONFIG_PATH` to the root tsconfig and pass `-
|
||||
|
||||
Do not inventory example tests here; the `tests/` trees and root scripts are authoritative.
|
||||
|
||||
In `cordis.yml`, comment only non-obvious wiring, load-order consequences, replay, security boundaries, and configuration scope. Do not narrate visible entries; use [dsh-trim-prose](../.agents/skills/dsh-trim-prose/SKILL.md) for example prose.
|
||||
In `cordis.yml`, comment only non-obvious wiring, load-order consequences, replay, security boundaries, and configuration scope. Do not narrate visible entries; use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage and editorial judgment.
|
||||
|
||||
See [the root AGENTS.md](../AGENTS.md) for repo-wide conventions and [docs/architecture.md](../docs/architecture.md) for the design.
|
||||
|
||||
27
examples/acp-agent/advanced.cordis.snapshot.yml
Normal file
27
examples/acp-agent/advanced.cordis.snapshot.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
# Replay counterpart to advanced.cordis.yml; only the live model is replaced.
|
||||
- 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'
|
||||
23
examples/acp-agent/advanced.cordis.yml
Normal file
23
examples/acp-agent/advanced.cordis.yml
Normal file
@@ -0,0 +1,23 @@
|
||||
# Add Code Mode and Cordis tools to the base spawn/workflow stack, exercising
|
||||
# all four boundaries in one ACP snapshot.
|
||||
- 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'
|
||||
@@ -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,18 @@ 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, childSessions: 1 },
|
||||
// Authored counterpart to the packaged Python SDK snapshot: mount a live marker, inspect it
|
||||
// through Code Mode, run direct and workflow children, then unmount it. The extra Code Mode and
|
||||
// Cordis plugins require their own request-header pin; the fixture tests deterministic composition.
|
||||
{
|
||||
name: 'advanced-toolchain',
|
||||
hasModelTurn: true,
|
||||
recorded: false,
|
||||
childSessions: 2,
|
||||
pinsHeader: true,
|
||||
headerClass: 'advanced',
|
||||
configPath: ADVANCED_CONFIG,
|
||||
},
|
||||
// Prompt-submit blocks are authored keylessly: they persist a rejected turn
|
||||
// and hook events without starting a model step, so their logs still compare.
|
||||
{ name: 'hook-cc-promptsubmit-block', hasModelTurn: false, comparesLog: true, recorded: false },
|
||||
|
||||
@@ -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." }
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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"}}
|
||||
@@ -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> 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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:<id>]`, 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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<string>;
|
||||
/** 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 <value>` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise<any>` — 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<any[]>` — 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<any[]>` — 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 <json-value>`). */
|
||||
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<string, unknown>;
|
||||
}): Promise<string>;
|
||||
/** 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<string>;
|
||||
}
|
||||
```
|
||||
@@ -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"]
|
||||
|
||||
@@ -46,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",
|
||||
@@ -65,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",
|
||||
|
||||
@@ -25,7 +25,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. 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<B>` primitive) | Support — small, stable, harness-dep-free |
|
||||
|
||||
|
||||
@@ -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).
|
||||
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,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'
|
||||
@@ -92,16 +93,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 {
|
||||
@@ -267,7 +271,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.
|
||||
|
||||
@@ -11,4 +11,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 })
|
||||
|
||||
@@ -5,17 +5,18 @@ import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports the package by name through its exports map
|
||||
* and exercises type stripping, worker loading, bindings, and logs. It skips when `lib/` is
|
||||
* absent; CI runs it after the build.
|
||||
* Keyless built-artifact smoke: plain Node imports the package by name through its exports map,
|
||||
* then exercises type stripping, sibling `worker.cjs` loading, bindings, and logs. Unit tests use
|
||||
* `src/worker.ts`; this pins the downstream `lib/index.js` path. It skips when `lib/` is absent,
|
||||
* and CI runs it after the build.
|
||||
*/
|
||||
|
||||
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')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Build the index and worker as separate single-entry bundles. The worker must be a sibling
|
||||
* file, while a multi-entry build would emit an unlisted shared chunk omitted by the package's
|
||||
* exact `files` whitelist.
|
||||
* Build the index and worker as separate single-entry bundles. The sibling `worker.cjs` is loaded
|
||||
* by file and must be CommonJS for pkg's VFS Worker hook. A multi-entry build emits an unlisted
|
||||
* shared chunk omitted by the package's exact `files` whitelist; separate builds inline it.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
@@ -19,7 +19,7 @@ export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/worker.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
|
||||
@@ -224,8 +224,10 @@ declare module 'cordis' {
|
||||
* Compose request-only messages placed before derived history. The frozen
|
||||
* result is computed once per loop instance, logged on its anchoring request
|
||||
* header, and reused so the provider prefix remains stable. Interrupted
|
||||
* composition is discarded. Changing context belongs in history; contributors
|
||||
* should prepend to `await next()` to preserve registration order.
|
||||
* composition is discarded. Composition precedes the first `agent/pre-step`
|
||||
* and request boundary, so listener appends join the current request and
|
||||
* pressure accounting sees the composed prefix. Changing context belongs in
|
||||
* history; contributors should prepend to `await next()` to preserve registration order.
|
||||
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent.
|
||||
* @param agent - the agent whose session prefix is being composed.
|
||||
* @param prefix - the frozen seed; return an extended replacement.
|
||||
|
||||
@@ -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 server for out-of-process SDK clients | (drives `ctx.agents`) |
|
||||
| `jsonrpc-agent/` | Bin-only SDK runtime app that boots an external `cordis.yml` | (`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 or capability seam: it consumes the existing `agent/*` events and `dsh-agent` factory. `jsonrpc` is the SDK-client sibling of the `acp` editor bridge. The readline UI lives inside [`stdio-agent/`](stdio-agent/README.md) because it is scaffolding for that front door, not an independently swappable integration.
|
||||
|
||||
`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` compose the [`agent-core`](../core/agent-core/README.md) spine with their front-door plugins and own their boot bins; a leaf `cordis.yml` supplies backends and optional tools. `jsonrpc-agent` is bin-only because its external config also chooses the serving `jsonrpc` plugin. Each lives in `ui/` as a user-facing front door whose artifact owns its stdout policy.
|
||||
|
||||
17
packages/ui/jsonrpc-agent/README.md
Normal file
17
packages/ui/jsonrpc-agent/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# @deepseek-ai/dsh-jsonrpc-agent
|
||||
|
||||
Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../jsonrpc/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. `lib/bin.js` is also the [single-executable runtime](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) entry.
|
||||
|
||||
## Config discovery
|
||||
|
||||
The first non-empty channel wins: `$DSH_CORDIS_CONFIG`, then positional `argv[2]`. If neither names an existing file, the bin prints one-line usage to stderr and exits 1; there is no working-directory or built-in fallback. [`dsh-app-boot`](../app-boot/README.md) makes plugin load failures fatal. This protocol does not use `DSH_SNAPSHOT`.
|
||||
|
||||
A config without `dsh-jsonrpc` is valid and serves nothing; the bin does not designate a server plugin.
|
||||
|
||||
## Exit lifecycle
|
||||
|
||||
stdin EOF and `SIGTERM` dispose the root to quiescence and exit 0; `SIGINT` exits 130 after the same disposal. EOF may cut off an in-flight turn as documented in the [distribution RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). The `jsonrpc` plugin owns response-before-exit protocol shutdown; both paths are idempotent and safe to race.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
stdout carries only JSON-RPC frames. The bin and boot guards diagnose on stderr, and the config must omit stdout loggers.
|
||||
41
packages/ui/jsonrpc-agent/package.json
Normal file
41
packages/ui/jsonrpc-agent/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jsonrpc-agent",
|
||||
"description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
51
packages/ui/jsonrpc-agent/src/bin.ts
Normal file
51
packages/ui/jsonrpc-agent/src/bin.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves
|
||||
* newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`;
|
||||
* empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode.
|
||||
* stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130.
|
||||
* Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames.
|
||||
*
|
||||
* @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 -- composition over tested app-boot/jsonrpc and executable acceptance paths */
|
||||
installFailLoud(NAME)
|
||||
loadEnv(NAME)
|
||||
|
||||
// Env wins over argv; empty values are absent. External config defines the deployment.
|
||||
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} <path/to/cordis.yml> (or set DSH_CORDIS_CONFIG=<path>, 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<void> {
|
||||
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 */
|
||||
9
packages/ui/jsonrpc-agent/src/index.ts
Normal file
9
packages/ui/jsonrpc-agent/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns
|
||||
* process exit. This module exports no composition plugin; the config chooses
|
||||
* whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-jsonrpc-agent
|
||||
*/
|
||||
|
||||
export {}
|
||||
21
packages/ui/jsonrpc-agent/tsconfig.json
Normal file
21
packages/ui/jsonrpc-agent/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
packages/ui/jsonrpc-agent/tsdown.config.ts
Normal file
15
packages/ui/jsonrpc-agent/tsdown.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* Build the doc-only module and CLI entry; `tsc -b` supplies declarations.
|
||||
*/
|
||||
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,
|
||||
})
|
||||
23
packages/ui/jsonrpc/README.md
Normal file
23
packages/ui/jsonrpc/README.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# @deepseek-ai/dsh-jsonrpc
|
||||
|
||||
Stdio JSON-RPC plugin for out-of-process SDK clients such as Python `deepseek_harness`. [`HarnessSdkServer`](src/server.ts) handles `initialize` → `session/prompt` → `shutdown` plus session and subagent notifications over [`JsonRpcLineTransport`](src/transport.ts). This package owns the protocol; [`jsonrpc-agent`](../jsonrpc-agent/README.md) boots the external `cordis.yml` that chooses the surrounding runtime. See the [single-executable RFC](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) for the distribution design.
|
||||
|
||||
## Wiring
|
||||
|
||||
`inject: ['agents']`. The server gets or creates one agent per `sessionId` on `session/prompt` and demuxes `subagent/end` through the registry. If `initialize.model` lacks a registered adapter, it mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`; a config-registered adapter wins. Persistence, tools, and other adapters come from the surrounding `cordis.yml`.
|
||||
|
||||
## Config
|
||||
|
||||
No `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are test-only runtime seams; production uses process stdio and `process.exit`.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
stdout carries only JSON-RPC frames. The loading config must omit stdout loggers; diagnostics go to stderr.
|
||||
|
||||
## Shutdown and exit semantics
|
||||
|
||||
A `shutdown` request flushes its response, disposes the plugin fiber, then exits 0. Disposal idempotently shuts down every SDK-created agent to quiescence, detaches subscriptions, and closes the transport. Bare fiber disposal only stops serving; it does not exit. The app bin owns root disposal for stdin EOF (0), SIGTERM (0), and SIGINT (130).
|
||||
|
||||
## Wire notes
|
||||
|
||||
`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. Each session permits one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and deployment persona remain in `cordis.yml`.
|
||||
46
packages/ui/jsonrpc/package.json
Normal file
46
packages/ui/jsonrpc/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-jsonrpc",
|
||||
"description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
83
packages/ui/jsonrpc/src/index.ts
Normal file
83
packages/ui/jsonrpc/src/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* SDK-facing JSON-RPC plugin over stdio. An external `cordis.yml` decides
|
||||
* whether to load it; see the single-executable RFC and package README.
|
||||
* Stdout is reserved for protocol frames, so the tree must not load a stdout logger.
|
||||
* This plugin answers `shutdown`, disposes its own fiber, and exits 0; the app bin
|
||||
* owns EOF and signal exits. Keep named plugin exports with no default export so
|
||||
* Loader `unwrapExports` preserves `name`, `inject`, `Config`, and `apply`.
|
||||
*
|
||||
* @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'
|
||||
// Only the agent factory is required; initialize reads the optional LLM seam with ctx.get().
|
||||
export const inject = ['agents']
|
||||
|
||||
/** Runtime-only test seams; no field is configurable from `cordis.yml`. */
|
||||
export interface JsonRpcConfig {
|
||||
/** Transport input override; production uses `process.stdin`. */
|
||||
input?: Readable
|
||||
/** Transport output override; production uses `process.stdout`. */
|
||||
output?: Writable
|
||||
/** Process-exit override; production uses `process.exit`. */
|
||||
exit?: (code: number) => void
|
||||
}
|
||||
|
||||
export const Config: Schema<JsonRpcConfig> = Schema.object({})
|
||||
|
||||
/**
|
||||
* Serve SDK requests over the configured streams. Effect disposal shuts down
|
||||
* SDK-created agents and closes the transport. A `shutdown` response is flushed
|
||||
* before this plugin's fiber is disposed and the process exits 0; the app bin
|
||||
* owns root-context disposal for EOF and signals.
|
||||
*/
|
||||
export function apply(ctx: Context, config: JsonRpcConfig): void {
|
||||
// The later transport callback must dispose this plugin's fiber, not its ambient context.
|
||||
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)
|
||||
|
||||
// Share one exit task and attempt flush and disposal independently before exiting.
|
||||
let exitTask: Promise<void> | undefined
|
||||
const disposeAndExit = (): Promise<void> => {
|
||||
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') {
|
||||
// Run after the handler result is written; the task then flushes, disposes, and exits.
|
||||
setImmediate(() => { void disposeAndExit() })
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
ctx.effect(() => {
|
||||
transport.start()
|
||||
return async () => {
|
||||
await server.shutdown()
|
||||
transport.close()
|
||||
}
|
||||
}, 'jsonrpc.serve')
|
||||
}
|
||||
262
packages/ui/jsonrpc/src/server.ts
Normal file
262
packages/ui/jsonrpc/src/server.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* JSON-RPC methods and notifications for SDK clients. Requests are
|
||||
* `initialize`, repeated `session/prompt`, then `shutdown`; notifications carry
|
||||
* durable session events, settled turns, and subagent lineage/outcomes. The
|
||||
* external `cordis.yml` owns plugins, persistence, and the adapter set.
|
||||
*
|
||||
* @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'
|
||||
|
||||
/** One-time SDK initialization parameters. */
|
||||
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
|
||||
}
|
||||
|
||||
/** SDK handshake result. */
|
||||
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[]
|
||||
}
|
||||
|
||||
/** Accepted prompt result; the outcome is reported by `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
|
||||
}
|
||||
|
||||
/**
|
||||
* SDK server over one booted harness context and transport peer. Construction
|
||||
* subscribes to session, agent, and subagent lifecycle events until shutdown;
|
||||
* reinitialization is unsupported.
|
||||
*/
|
||||
export class HarnessSdkServer {
|
||||
private cwd = process.cwd()
|
||||
private model = 'deepseek'
|
||||
private llmFiber: { dispose(): Promise<void> } | undefined
|
||||
private readonly sessions = new Map<string, SessionRecord>()
|
||||
private readonly sessionCreations = new Map<string, Promise<SessionRecord>>()
|
||||
private readonly subagentSessions = new Map<string, SubagentRecord>()
|
||||
private readonly disposers: (() => void)[] = []
|
||||
private shutdownTask: Promise<Record<string, never>> | 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 lineage before child disposal removes the agent 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 }),
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Record cwd and model, mounting the DeepSeek adapter only when the config
|
||||
* registered no adapter for that model.
|
||||
* @param params - the SDK handshake parameters.
|
||||
* @returns the server identity for the handshake.
|
||||
*/
|
||||
async initialize(params: InitializeParams): Promise<InitializeResult> {
|
||||
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' } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the session agent, send the prompt, await quiescence, then
|
||||
* notify `session.finished`. A session accepts one prompt at a time; 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<SessionPromptResult> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispose SDK-created agents to quiescence, unmount the server-mounted adapter,
|
||||
* and detach subscriptions. The surrounding context remains running.
|
||||
* @returns an empty object (the JSON-RPC result).
|
||||
*/
|
||||
shutdown(): Promise<Record<string, never>> {
|
||||
this.shutdownTask ??= this.performShutdown()
|
||||
return this.shutdownTask
|
||||
}
|
||||
|
||||
private async performShutdown(): Promise<Record<string, never>> {
|
||||
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 an incoming request; unknown methods throw for transport conversion
|
||||
* to a JSON-RPC error response.
|
||||
* @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<string, unknown> | undefined): Promise<unknown> {
|
||||
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<SessionRecord> {
|
||||
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<SessionRecord> {
|
||||
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
|
||||
}
|
||||
}
|
||||
223
packages/ui/jsonrpc/src/transport.ts
Normal file
223
packages/ui/jsonrpc/src/transport.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* Newline-delimited JSON-RPC 2.0 over byte streams. Frames with `id` and
|
||||
* `method` are requests, `id` alone is a response, and `method` alone is a
|
||||
* notification. Malformed lines are ignored; handler failures become error frames.
|
||||
*
|
||||
* @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<string, unknown>) => Promise<unknown>
|
||||
type NotificationHandler = (method: string, params: Record<string, unknown>) => void
|
||||
|
||||
/**
|
||||
* Outbound request and notification surface used by {@link HarnessSdkServer}.
|
||||
*/
|
||||
export interface JsonRpcTransportPeer {
|
||||
/**
|
||||
* Send a request and await its response.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the request parameters object.
|
||||
* @returns the result; rejects on an error response, write failure, or closure.
|
||||
*/
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
* Send a notification; omitted params produce no `params` member.
|
||||
* @param method - the JSON-RPC method name.
|
||||
* @param params - the optional notification parameters object.
|
||||
*/
|
||||
notify(method: string, params?: Record<string, unknown>): void
|
||||
}
|
||||
|
||||
interface PendingRequest {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-delimited endpoint over caller-owned streams. {@link start} attaches
|
||||
* listeners; {@link close} detaches them and rejects pending requests without
|
||||
* destroying the streams. Missing request handlers return `-32601`; handler
|
||||
* failures return `-32603`. Notifications without a handler are dropped.
|
||||
*/
|
||||
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<JsonRpcId, PendingRequest>()
|
||||
|
||||
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 listeners and reject pending requests. Safe before {@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 request handler, replacing any prior handler.
|
||||
* @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 notification handler, replacing any prior handler.
|
||||
* @param handler - invoked per notification with the method and normalized
|
||||
* params object.
|
||||
*/
|
||||
onNotification(handler: NotificationHandler): void {
|
||||
this.notificationHandler = handler
|
||||
}
|
||||
|
||||
request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): void {
|
||||
this.write(params === undefined ? { jsonrpc: '2.0', method } : { jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for prior frame write callbacks. The empty barrier emits no bytes.
|
||||
* @returns a promise that settles with the output write callback.
|
||||
*/
|
||||
flush(): Promise<void> {
|
||||
return new Promise<void>((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<void> {
|
||||
let message: unknown
|
||||
try {
|
||||
message = JSON.parse(line)
|
||||
} catch {
|
||||
// Only JSON syntax errors reach this catch; malformed peer lines are ignored.
|
||||
return
|
||||
}
|
||||
if (!message || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<string, unknown>): 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<string, unknown>
|
||||
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<string, unknown>): 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<string, unknown> {
|
||||
return params && typeof params === 'object' && !Array.isArray(params) ? params as Record<string, unknown> : {}
|
||||
}
|
||||
293
packages/ui/jsonrpc/tests/plugin-apply.spec.ts
Normal file
293
packages/ui/jsonrpc/tests/plugin-apply.spec.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* Mount the real namespace plugin with in-memory stdio and exit seams. Covers
|
||||
* the full transport/server path, response-before-exit shutdown exactly once,
|
||||
* and bare-fiber disposal without process exit.
|
||||
*/
|
||||
|
||||
/** One ordered frame, write completion, or exit observation. */
|
||||
type WireEvent =
|
||||
| { kind: 'frame'; frame: Record<string, unknown> }
|
||||
| { kind: 'write-complete'; ids: (string | number)[] }
|
||||
| { kind: 'exit'; code: number }
|
||||
|
||||
interface ApplyHarness {
|
||||
ctx: Context
|
||||
/** The plugin fiber used by the bare-dispose case. */
|
||||
fiber: Awaited<ReturnType<Context['plugin']>>
|
||||
/** Frames, write completions, and exits in observation order. */
|
||||
events: WireEvent[]
|
||||
outputErrors: Error[]
|
||||
send(frame: Record<string, unknown>): void
|
||||
sendRaw(text: string): void
|
||||
frames(): Record<string, unknown>[]
|
||||
exits(): number[]
|
||||
waitForFrame(predicate: (frame: Record<string, unknown>) => boolean, description: string): Promise<Record<string, unknown>>
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/** Poll asynchronous output for up to five seconds. */
|
||||
async function waitFor<T>(get: () => T | undefined, description: string): Promise<T> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
/** Drain asynchronous work before a negative assertion. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 25))
|
||||
}
|
||||
|
||||
/** Mount the real plugin on a minimal harness with in-memory stdio and exit. */
|
||||
async function mountPlugin(
|
||||
storageDir: string,
|
||||
options: { writeDelayMs?: number; failFlush?: boolean } = {},
|
||||
): Promise<ApplyHarness> {
|
||||
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 = ''
|
||||
// Record frame admission separately from write completion so delayed output
|
||||
// tests the 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<string, unknown>
|
||||
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<string, unknown>[] =>
|
||||
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()
|
||||
})
|
||||
|
||||
/** Keyless SSE endpoint for completing a prompt turn. */
|
||||
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<void>(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')
|
||||
|
||||
// Notifications use the same transport and arrive as id-less frames.
|
||||
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 {
|
||||
// One chunk makes the two deferred exit callbacks race.
|
||||
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])
|
||||
|
||||
// Both response writes and the flush barrier complete before exit.
|
||||
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)
|
||||
|
||||
await settle()
|
||||
expect(harness.exits()).toEqual([0])
|
||||
|
||||
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 handler-rejection path is live before disposal.
|
||||
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()
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
22
packages/ui/jsonrpc/tests/plugin-shape.spec.ts
Normal file
22
packages/ui/jsonrpc/tests/plugin-shape.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as jsonrpc from '../src/index.ts'
|
||||
|
||||
/**
|
||||
* Run the real namespace export through `Loader.unwrapExports`; a stray
|
||||
* default would discard `name`, `inject`, `Config`, and `apply`.
|
||||
*/
|
||||
describe('dsh-jsonrpc plugin export shape', () => {
|
||||
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
|
||||
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<string, unknown>
|
||||
expect(unwrapped).toBe(jsonrpc)
|
||||
expect(unwrapped.name).toBe('jsonrpc')
|
||||
expect(unwrapped.inject).toEqual(['agents'])
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
})
|
||||
})
|
||||
572
packages/ui/jsonrpc/tests/server.spec.ts
Normal file
572
packages/ui/jsonrpc/tests/server.spec.ts
Normal file
@@ -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<string, unknown> }[] = []
|
||||
|
||||
async request(method: string, params: Record<string, unknown>): Promise<unknown> {
|
||||
throw new Error(`the SDK server should not call host JSON-RPC method ${method} with ${JSON.stringify(params)}`)
|
||||
}
|
||||
|
||||
notify(method: string, params?: Record<string, unknown>): 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<void>(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<void> {
|
||||
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<void>((resolve) => { releaseMain = resolve })
|
||||
const mainWhenIdle = vi.fn<() => Promise<void>>()
|
||||
.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<Record<string, never>>
|
||||
}
|
||||
|
||||
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<Record<string, never>>
|
||||
}
|
||||
|
||||
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<AgentHandle>((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<AgentHandle>>()
|
||||
.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<Record<string, never>>
|
||||
}
|
||||
|
||||
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<AgentHandle>>()
|
||||
.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<unknown>
|
||||
getOrCreateSession(sessionId: string): Promise<unknown>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
|
||||
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<string, { handle: AgentHandle; lastTurnEnd: undefined; activePrompt: boolean }>
|
||||
shutdown(): Promise<Record<string, never>>
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
260
packages/ui/jsonrpc/tests/transport.spec.ts
Normal file
260
packages/ui/jsonrpc/tests/transport.spec.ts
Normal file
@@ -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<string, unknown>[] = []
|
||||
|
||||
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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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<string, unknown>[] = []
|
||||
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()
|
||||
})
|
||||
})
|
||||
33
packages/ui/jsonrpc/tsconfig.json
Normal file
33
packages/ui/jsonrpc/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 installs TypeScript transforms through a data-URL bootstrap; built mode passes sibling `lib/worker.cjs` as a filesystem path because pkg's VFS hook expects CommonJS. Both work under ordinary Node. A ready/go handshake prevents a start-signal cancellation racing worker boot from executing the script's initial synchronous slice.
|
||||
|
||||
For each `agent()` call:
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -8,6 +8,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'
|
||||
@@ -33,12 +34,12 @@ interface ChildRecord {
|
||||
* environment; the unbuilt shape forwards only `TSX_TSCONFIG_PATH` for path
|
||||
* resolution.
|
||||
* @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 only for unbuilt consumers and install it before importing TS.
|
||||
const workerEntry = new URL('./worker.ts', import.meta.url)
|
||||
|
||||
@@ -8,21 +8,16 @@ 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
|
||||
* node (no tsx loader). Keyless — a zero-agent script needs no provider —
|
||||
* and self-skips until `pnpm run build` has produced the bundles.
|
||||
* Keyless built-artifact guard: plain Node loads `lib/index.js` and its sibling
|
||||
* `lib/worker.cjs` without tsx. Skips until the build produces both 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
|
||||
// temp-named file at the package root, removed on the way out.
|
||||
// Keep the driver in-package so bare imports resolve its node_modules.
|
||||
const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`)
|
||||
try {
|
||||
await writeFile(driver, `
|
||||
@@ -36,7 +31,7 @@ await ctx.plugin(WorkerWorkflowEngine, {})
|
||||
const run = ctx.workflows.start({
|
||||
script: 'return 6 * 7',
|
||||
meta: { name: 'built-smoke', description: 'built worker smoke' },
|
||||
// A zero-agent script never touches the provider, so a bare id suffices.
|
||||
// A zero-agent script never touches the provider.
|
||||
parent: { id: 'built-smoke-parent', options: {} },
|
||||
})
|
||||
const result = await run.result
|
||||
@@ -47,7 +42,6 @@ if (result.stopReason !== 'completed' || result.value !== 42) {
|
||||
}
|
||||
console.log('built-worker-smoke-ok')
|
||||
`, 'utf8')
|
||||
// Plain node — no tsx loader anywhere; the bundle must stand on its own.
|
||||
const { stdout } = await run(process.execPath, [driver], { cwd: packageRoot, timeout: 60_000 })
|
||||
expect(stdout).toContain('built-worker-smoke-ok')
|
||||
} finally {
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/**
|
||||
* The engine ships two runtime entries: the engine service (index) and the
|
||||
* worker-thread entry (worker) the engine spawns via `new Worker`. The
|
||||
* 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).
|
||||
* Build the engine and worker separately so each inlines shared modules; a
|
||||
* multi-entry build creates an unlisted chunk. The path-loaded worker is
|
||||
* CommonJS because pkg's VFS Worker hook compiles it in that format.
|
||||
*/
|
||||
export default defineConfig([
|
||||
{
|
||||
@@ -22,7 +19,7 @@ export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/worker.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
format: ['cjs'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
|
||||
258
pnpm-lock.yaml
generated
258
pnpm-lock.yaml
generated
@@ -1258,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':
|
||||
@@ -1582,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':
|
||||
@@ -4952,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)
|
||||
@@ -5968,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
|
||||
|
||||
@@ -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:
|
||||
|
||||
5
pytest.ini
Normal file
5
pytest.ini
Normal file
@@ -0,0 +1,5 @@
|
||||
# Restrict root collection to SDK tests; recursive collection can include
|
||||
# ignored worktrees or venvs and collide on same-named modules.
|
||||
[pytest]
|
||||
testpaths = python/sdk/tests
|
||||
norecursedirs = node_modules .git dist-exe
|
||||
6
python/README.i18n.yaml
Normal file
6
python/README.i18n.yaml
Normal file
@@ -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
|
||||
76
python/README.md
Normal file
76
python/README.md
Normal file
@@ -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>-<arch>` (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.
|
||||
76
python/README.zh.md
Normal file
76
python/README.zh.md
Normal file
@@ -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>-<arch>`(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 任何进程。
|
||||
6
python/sdk-runtime/README.i18n.yaml
Normal file
6
python/sdk-runtime/README.i18n.yaml
Normal file
@@ -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
|
||||
29
python/sdk-runtime/README.md
Normal file
29
python/sdk-runtime/README.md
Normal file
@@ -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>-<arch>` (platform: `linux`/`macos`; arch: `x64`/`arm64`). No Node installation needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists.
|
||||
- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-agent/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions.
|
||||
|
||||
Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding.
|
||||
|
||||
Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or 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.
|
||||
29
python/sdk-runtime/README.zh.md
Normal file
29
python/sdk-runtime/README.zh.md
Normal file
@@ -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>-<arch>`(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))。因此,零配置是包装层中一次显式、可见的参数传递,而不是运行时中的隐藏回退。
|
||||
60
python/sdk-runtime/hatch_build.py
Normal file
60
python/sdk-runtime/hatch_build.py
Normal file
@@ -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}"
|
||||
74
python/sdk-runtime/package.json
Normal file
74
python/sdk-runtime/package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "dsh-jsonrpc-agent-pkg",
|
||||
"description": "Dependency-only deploy root defining the executable and Python runtime closure; pnpm deploy materializes this manifest and 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:^"
|
||||
}
|
||||
}
|
||||
24
python/sdk-runtime/pyproject.toml
Normal file
24
python/sdk-runtime/pyproject.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[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" }
|
||||
|
||||
# Include the injected executable and default config; exclude the dev-only node
|
||||
# closure from wheels and sdists.
|
||||
[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]
|
||||
151
python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
Normal file
151
python/sdk-runtime/src/deepseek_harness_runtime/__init__.py
Normal file
@@ -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>-<arch>`` (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",
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
{"name":"deepseek-harness-runtime-bin","version":"0.0.0-dev"}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Bundled default config. The runtime still requires an explicit
|
||||
# $DSH_CORDIS_CONFIG or argv path; the SDK injects this path for bundled
|
||||
# zero-config launches. SDK-set environment variables have manual-run fallbacks.
|
||||
|
||||
# Stdio JSON-RPC serving surface; without it the agent has no SDK client.
|
||||
- id: jsonrpc
|
||||
name: '@deepseek-ai/dsh-jsonrpc'
|
||||
|
||||
# Agent spine; the SDK server creates agents per sessionId.
|
||||
- id: agent-core
|
||||
name: '@deepseek-ai/dsh-agent-core'
|
||||
|
||||
# Stock DeepSeek adapters. Loading requires an API key; initialize and shutdown
|
||||
# may use a dummy key because they do not call the model.
|
||||
- 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 persistence; $DSH_SESSION_ROOT wins over ./.sessions in the process cwd.
|
||||
- id: sessions
|
||||
name: '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
config:
|
||||
root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions'
|
||||
|
||||
# Local bash executor; $DSH_CWD wins over the process cwd.
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
cwd: !!js process.env.DSH_CWD ?? process.cwd()
|
||||
6
python/sdk/README.i18n.yaml
Normal file
6
python/sdk/README.i18n.yaml
Normal file
@@ -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
|
||||
40
python/sdk/README.md
Normal file
40
python/sdk/README.md
Normal file
@@ -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`.
|
||||
34
python/sdk/README.zh.md
Normal file
34
python/sdk/README.zh.md
Normal file
@@ -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` 的高层便捷选项。
|
||||
30
python/sdk/pyproject.toml
Normal file
30
python/sdk/pyproject.toml
Normal file
@@ -0,0 +1,30 @@
|
||||
[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 installs see runtime executables injected after installation instead
|
||||
# of freezing a wheel snapshot.
|
||||
[tool.uv.sources]
|
||||
deepseek-harness-runtime-bin = { path = "../sdk-runtime", editable = true }
|
||||
17
python/sdk/src/deepseek_harness/__init__.py
Normal file
17
python/sdk/src/deepseek_harness/__init__.py
Normal file
@@ -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",
|
||||
]
|
||||
195
python/sdk/src/deepseek_harness/api.py
Normal file
195
python/sdk/src/deepseek_harness/api.py
Normal file
@@ -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 ""
|
||||
503
python/sdk/src/deepseek_harness/client.py
Normal file
503
python/sdk/src/deepseek_harness/client.py
Normal file
@@ -0,0 +1,503 @@
|
||||
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:
|
||||
"""Inject the default config for a bundled launch with no non-empty config.
|
||||
|
||||
Both bundled carriers require an explicit config. Explicit runtime,
|
||||
launch-argument, and config channels remain untouched.
|
||||
"""
|
||||
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
|
||||
# _default_launch_args already imported the package or raised its install error.
|
||||
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
|
||||
19
python/sdk/src/deepseek_harness/errors.py
Normal file
19
python/sdk/src/deepseek_harness/errors.py
Normal file
@@ -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
|
||||
32
python/sdk/src/deepseek_harness/models.py
Normal file
32
python/sdk/src/deepseek_harness/models.py
Normal file
@@ -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
|
||||
120
python/sdk/tests/manual_sdk_agent_smoke.py
Normal file
120
python/sdk/tests/manual_sdk_agent_smoke.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""Drive the repo-source JSON-RPC bin through the SDK and a keyless mock SSE server.
|
||||
|
||||
Requires ``pnpm install`` but no build. This manual test is not collected by
|
||||
pytest; run ``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()
|
||||
114
python/sdk/tests/test_bundled_runtime.py
Normal file
114
python/sdk/tests/test_bundled_runtime.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""Keyless installed-SDK boot tests for the executable and node carriers.
|
||||
|
||||
Each carrier skips independently when absent. The dummy API key only satisfies
|
||||
adapter loading; initialize and shutdown do not 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 config must include the JSON-RPC serving plugin.
|
||||
_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),
|
||||
# The lazily mounted adapter requires a key even without a model call.
|
||||
"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:
|
||||
pass
|
||||
765
python/sdk/tests/test_client.py
Normal file
765
python/sdk/tests/test_client.py
Normal file
@@ -0,0 +1,765 @@
|
||||
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:
|
||||
"""Install a fake runtime package that records config and serves lifecycle calls.
|
||||
|
||||
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()
|
||||
39
python/sdk/tests/test_release_version.py
Normal file
39
python/sdk/tests/test_release_version.py
Normal file
@@ -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)
|
||||
38
python/sdk/tests/test_runtime_resolution.py
Normal file
38
python/sdk/tests/test_runtime_resolution.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Keyless runtime-resolution tests; launch 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"))
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user