Back to home

42ch-dev

dsh-rust-sdk

Rust SDK for DSH(Deepseek Harness)

Stars
2
Language
Rust
Created
Aug 16, 2026
Updated
Aug 16, 2026

Introduction

deepseek-harness-sdk

English | 中文

License Language crates.io

See the CHANGELOG for the release history.

Rust client SDK for the DeepSeek Harness (DSH) runtime: it spawns the official runtime as a subprocess and speaks its stdio JSON-RPC 2.0 protocol. One crate, two layers: the high-level Python-parity API (DeepSeekHarness / Session::run / RunResult) and the low-level protocol client (HarnessClient).

The crate is the design twin of the official Python SDK, sharing the same runtime peer, wire protocol, and layering; the Python SDK surface is the alignment baseline for every public type and error. The TypeScript SDK's divergences are documented (notably RunResult, see below).

This crate is a pure client. It contains no agent, LLM, or persistence logic — the spawned runtime process does all of that. The runtime binary is bring-your-own: this crate never downloads, bundles, or ships one.

Installation

cargo add deepseek-harness-sdk@0.1.0-alpha.1

or in Cargo.toml:

[dependencies]
deepseek-harness-sdk = "0.1.0-alpha.1"

Pre-release note: 0.1.0-alpha.1 is a pre-release version — request it explicitly (with @0.1.0-alpha.1 or the full version string). A bare cargo add deepseek-harness-sdk will not resolve to a pre-release. The API may still change before 0.1.0.

Two prerequisites before the first run: a DSH runtime (see Runtime acquisition) and model credentials (DEEPSEEK_API_KEY in the environment, or Config::api_key / Config::base_url).

Quickstart

use deepseek_harness_sdk::{Config, DeepSeekHarness, Input};
use std::time::Duration;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let mut harness = DeepSeekHarness::start(Config {
        runtime_bin: std::env::var("DSH_RUNTIME_BIN").ok(),
        request_timeout: Some(Duration::from_secs(120)),
        ..Config::default()
    })
    .await?;

    let session = harness.start_session(None);
    let result = session
        .run(Input::Text("Reply with exactly: ok".into()))
        .await?;

    println!("finish_reason: {:?}", result.finish_reason);
    println!("final_response: {}", result.final_response);

    harness.close().await?;
    Ok(())
}

DeepSeekHarness::start is eager: it resolves the runtime, spawns the subprocess, and completes the initialize handshake before returning. Like the other SDKs, the runtime inherits DEEPSEEK_BASE_URL / DEEPSEEK_API_KEY from the environment, so callers can use real model endpoints directly or point those variables at a local proxy.

Runtime acquisition

The runtime is bring-your-own; the SDK only locates it. The DSH runtime is an npm-distributed Node.js program (dsh-jsonrpc-agent); upstream also ships a self-contained single-file executable via a Python wheel. Pick one route:

Route A — npm (default)

The runtime bin is published as @deepseek-ai/dsh-sdk-jsonrpc-demo (bin: dsh-jsonrpc-agent). Requires Node.js ≥ 22.19. Run it either way:

  • npx (no install)npx itself is the program, so use Config::launch_args_override:

    Config {
        launch_args_override: Some(vec![
            "npx".into(),
            "--yes".into(),
            "@deepseek-ai/dsh-sdk-jsonrpc-demo".into(),
        ]),
        ..Config::default()
    }
    
  • npm install -g — the bin lands on PATH:

    npm install -g @deepseek-ai/dsh-sdk-jsonrpc-demo
    export DSH_RUNTIME_BIN=dsh-jsonrpc-agent
    

Either way, the npm bin resolves the plugins named in cordis.yml from the config project — the directory the config file lives in — so this route also needs a small config project with the plugin set installed:

mkdir dsh-runtime && cd dsh-runtime
npm init -y >/dev/null
npm install @deepseek-ai/dsh-sdk-jsonrpc-server @deepseek-ai/dsh-agent-spine-demo \
  @deepseek-ai/dsh-llm-deepseek @deepseek-ai/dsh-session-persistence-jsonl \
  @deepseek-ai/dsh-session-checkpoint-policy @deepseek-ai/dsh-subprocess-local \
  @deepseek-ai/dsh-bash-local @deepseek-ai/dsh-fs-local
# Drop the default cordis.yml next to package.json (see below), then:
export DSH_CORDIS_CONFIG="$PWD/cordis.yml"

For step 3, use the upstream default config — python/sdk-runtime/src/deepseek_harness_runtime/runtime/cordis.yml in the DSH repository — or compose your own (keep the @deepseek-ai/dsh-sdk-jsonrpc-server entry; without it the runtime serves nothing).

npm route caveat: the npm bin has no built-in plugin tree — plugin load failures are fatal, and the SDK's bundled default config (extracted to a temp directory, no node_modules beside it) cannot satisfy plugin resolution. That is why this route sets DSH_CORDIS_CONFIG explicitly.

Route B — prebuilt single-file executable (wheel)

Upstream packs the same runtime as a self-contained Node.js single-file executable (no Node.js needed at runtime; plugin tree embedded) and distributes it through the Python SDK's deepseek-harness-runtime-bin platform wheel (linux-x64, linux-arm64, macos-arm64):

python -m pip install deepseek-harness-runtime-bin
export DSH_RUNTIME_BIN="$(python -c 'import deepseek_harness_runtime as r; print(r.bundled_runtime_path())')"

The python -c invocation only locates the installed executable and prints its path — no Python runs at SDK runtime. On macOS the executable needs its sibling -spawn-helper file in the same directory (the wheel installs both) — if you copy the executable elsewhere, copy the helper too.

This route works out of the box with the SDK-injected bundled default cordis.yml (see below): no DSH_CORDIS_CONFIG needed.

How the SDK resolves the runtime (reference)

The binary is resolved with Python HarnessClient parity, plus the Rust-only DSH_RUNTIME_BIN route:

  1. Config::launch_args_override (non-empty) — the whole argv, verbatim (the npx variant of Route A uses this);
  2. Config::runtime_bin;
  3. DSH_RUNTIME_BIN from the parent environment;
  4. otherwise Error::RuntimeNotFound, whose message names the acquisition routes.

An empty launch_args_override and an empty DSH_RUNTIME_BIN both count as absent (Python truthiness), so resolution never produces an unlaunchable empty program.

Bundled default config. With no effective DSH_CORDIS_CONFIG, DeepSeekHarness::start injects a bundled copy of the runtime's default cordis.yml (byte-identical to the official default), extracted to the system temp directory on first use and byte-verified on every use — the runtime refuses to boot without an explicit config, so this injection is required, and an extraction/verification failure propagates as Error::Io (never a silent config-less launch). Note the npm route caveat: the bundled default only resolves plugins when the runtime carries its own plugin tree (Route B's executable); with the npm bin, always provide DSH_CORDIS_CONFIG yourself.

Deliberate divergence from the Python SDK (documented; do not "fix"): Python injects its bundled default only when its bundled runtime carrier is used. This crate has no bundled carrier (bring-your-own runtime), so the default is injected whenever no effective config exists, regardless of how the binary was resolved.

API walkthrough

Layering

  • HarnessClient (low-level): spawns the runtime process, owns the stdio transport, speaks the JSON-RPC 2.0 wire protocol, and fans notifications out to subscriptions. Exposes LaunchSpec, ClientTimeouts, and NotificationSubscription.
  • DeepSeekHarness / Session (high-level): the Python-parity owned-run API on top of HarnessClient.
  • Input accepts either plain text (Input::Text) or raw content blocks (Input::Blocks), mirroring Python's normalize_input.

DeepSeekHarness::start

start is eager: it resolves the runtime, composes the environment injection set, spawns the subprocess, and performs the initialize handshake before returning. (This differs from the Python and TypeScript SDKs, which start lazily on first use.) A failed handshake runs the close ladder before the error propagates, so the spawned child is never leaked (Python parity).

Config::cwd is resolved absolute (Python Path(cwd).resolve()) and feeds both DSH_CWD and initialize.cwd; a nonexistent cwd fails with Error::Io. Config::request_timeout bounds every request, including session/prompt; None (the default) waits indefinitely.

Sessions created by start_session may run concurrently: the harness owns the spawned child behind an async mutex, sessions interleave at the session/prompt write, and each waits on its own subscription.

Session::run — one activity interval

run implements the Python Session.run algorithm verbatim:

  1. Subscribe to the session tree before writing the prompt, so no notification for this turn can be missed.
  2. Send session/prompt (bounded by Config::request_timeout).
  3. Wait for the durable agent/inbox/spliced receipt whose inserted[].id equals the returned message id (the field is id, not messageId); notifications before the receipt are dropped from both events and notifications.
  4. Collect — from the receipt inclusive — every tree notification until the root session reports session.status == "idle" (that idle notification is collected too; a non-root idle never terminates the run).

events holds root-session session.event payloads only; notifications holds every tree notification (root + discovered descendants, incl. session.status / subagent.*) in transport order.

Both waits — the receipt wait and the idle wait — are unbounded (Python parity); only the session/prompt request is bounded by Config::request_timeout. Callers needing a bound wrap the call in tokio::time::timeout — this bounds the local wait, not the runtime's turn. A session.event / session.status notification whose payload does not match the wire shape fails the run with Error::SdkProtocol (the Python SDK raises; Rust surfaces the same condition as a typed error instead of silently dropping an event or misreading the idle termination).

RunResult

RunResult follows the Python SDK field set. The TypeScript SDK's RunResult lacks finish_reason and session_root; Rust intentionally follows Python, not TypeScript:

FieldPythonTypeScriptRust (this crate)
session_id / sessionIdyesyessession_id: String
final_response / finalResponseyesyesfinal_response: String
finish_reasonyes (Python extension)nofinish_reason: Option<String>
eventsyes (root session only)yesevents: Vec<serde_json::Value>
notificationsyes (root + descendants, transport order)yesnotifications: Vec<Notification>
session_rootyes (Python extension)nosession_root: Option<PathBuf>

(The table is mirrored in the crate rustdoc, # Compatibility; keep the two copies in sync.)

Both derived fields describe the owned activity interval rather than an output causally assigned to the prompt: final_response is the last committed root-session assistant text in the interval — steering, injected context, and other queued work may contribute before idle — and finish_reason is the kind of the last root-session turn/end in the interval (such as completed, max-tokens, or error), None when no turn ended. A turn/end without a string data.reason.kind violates the runtime protocol and fails with Error::SdkProtocol.

Typed errors

All failure paths return Error variants instead of ad-hoc strings:

VariantMeaning
Error::RuntimeNotFoundNo runtime binary configured anywhere; message names the acquisition routes
Error::TransportClosedRuntime process not running, or stdio closed unexpectedly; carries diagnostics (exit status and captured stderr tail)
Error::RequestTimeoutA request got no response within the configured timeout; carries the method name
Error::SdkProtocolA protocol-level violation (missing server identity, missing messageId, finish_reason extraction failure, malformed notifications, subscription lag); Error::is_protocol() detects it
Error::JsonRpcA JSON-RPC error response, preserving code (Option<i64>) and optional data
Error::Io / Error::JsonI/O (spawn, stdio, transport) and JSON serialization/deserialization errors

Close ladder

DeepSeekHarness::close (and HarnessClient::close) runs the close ladder: a cooperative shutdown request bounded by shutdown_timeout (default 1s, diagnostic only on failure) → drop stdin (EOF) → wait eof_grace (default 6s — the runtime gets time to flush durable state after stdin closes) → SIGTERM → wait term_grace (default 3s) → SIGKILL → wait. The ladder is idempotent, is unconditional teardown (failure at any tier still reaps the child — the child is also killed on drop, so a ladder failure cannot strand the process), and resolves all pending requests with Error::TransportClosed.

Notifications

Tree notifications travel a broadcast channel capped at 4096 with drop-oldest semantics. If a high-volume tree floods more notifications than fit between the SDK's reads, the dropped set can include the inbox receipt or the root-idle notification a run depends on — rather than hang forever or return a silently truncated result, Session::run then fails fast with Error::SdkProtocol. A caller expecting very large bursts can bypass the cap only via the low-level HarnessClient::spawn_with_broadcast_capacity instead of DeepSeekHarness::start.

Environment variables

The parent environment is inherited wholesale; the SDK injects or overrides only the keys below (SDK keys win over caller-provided Config::env entries — Python dict.update semantics):

VariableRoleSemantics
DSH_RUNTIME_BINRuntime binary resolutionConsulted when neither Config::launch_args_override nor Config::runtime_bin is set; empty counts as absent
DSH_CORDIS_CONFIGRuntime composition configConfig::cordis_config (non-empty) wins; otherwise a non-empty value from Config::env or the parent environment is inherited as-is. Empty strings count as absent — an empty-string Config::env entry is skipped on copy so it can never clobber a non-empty parent value. With no effective value, the SDK injects the bundled default cordis.yml
DEEPSEEK_BASE_URL / DEEPSEEK_API_KEYModel endpoint and credentialsInherited as-is; overridden only when Config::base_url / Config::api_key is configured
DSH_CWDAgent working directoryAlways injected, from Config::cwd (resolved absolute)
DSH_SESSION_ROOTSession rootInjected only when Config::session_root is configured; surfaced on every RunResult

Testing

  • cargo test — wire-protocol, lifecycle, and Session::run semantics suites against a scripted fake runtime (no real runtime needed).
  • tests/real_runtime.rs — a single smoke test that runs only when both DSH_RUNTIME_BIN and DEEPSEEK_API_KEY are set; otherwise it prints an explicit skip notice and passes, so cargo test is green with no runtime and no credentials present.

Platform support & MSRV

The SDK itself is pure Rust and platform-light; the consumed runtime decides the platform matrix. Route A (npm) runs wherever Node.js ≥ 22.19 does; Route B (wheel executable) ships linux-x64, linux-arm64, macos-arm64. No Windows: upstream has no Windows runtime builds.

MSRV: current stable Rust (no minimum is pinned in Cargo.toml; the crate tracks the stable toolchain).

Known limitations

  • Pre-release software0.1.0-alpha.1; the API may change before 0.1.0. The real-runtime smoke test is environment-gated (see Testing); the fake-runtime suites carry protocol correctness.
  • No mid-turn cancel — there is no session-close / cancel RPC on the wire. Session::run waits until the root session reports idle; closing the harness mid-turn abandons the in-flight turn. A Config::request_timeout only abandons the local wait — the server-side work still runs until close.
  • No version negotiation — the runtime identifies as serverInfo 0.0.1 pre-release, and initialize enforces a strict serverInfo.name check (deepseek-harness-sdk-runtime) with version required: the protocol declares the name wire-stable and has no negotiation, so an unexpected identity is a hard Error::SdkProtocol.
  • No runtime binary delivery / bundling / download — the runtime companion crate is a roadmap item, not part of this version. Acquire the runtime per Runtime acquisition.
  • No TypeScript-parity helper — the TS-shaped RunResult (without finish_reason / session_root) is not provided.

License

Apache-2.0.