dsh-rust-sdk
Discovered★ 8Rust SDK for DSH(Deepseek Harness)
deepseek-harness-sdk
English | 中文
See the CHANGELOG for the release history.
Rust client SDK for the DeepSeek Harness
(DSH) runtime. The runtime is the dsh CLI booted under a named profile —
this crate spawns it as a subprocess (dsh --profile sdk, the profile
default) 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), as are the
crate's own deliberate divergences from both references (see
Deliberate divergences).
This crate is a pure client. It contains no agent, LLM, or persistence logic — the spawned runtime process does all of that. The runtime is bring-your-own: this crate never downloads, bundles, or ships one (see Runtime acquisition).
Installation
cargo add deepseek-harness-sdk
or in Cargo.toml:
[dependencies]
deepseek-harness-sdk = "*"
Pick the version that suits you (cargo search deepseek-harness-sdk or the
crates.io page shows the
latest). While the crate is on a pre-release line, a bare
cargo add deepseek-harness-sdk may not resolve to the newest pre-release —
request it explicitly (e.g. cargo add deepseek-harness-sdk@0.1.0-alpha) when
you want it. 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 {
dsh_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()), None)
.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, resolves (and
creates) the harness home, spawns the subprocess, and completes the
initialize handshake before returning. The runtime inherits
DEEPSEEK_BASE_URL / DEEPSEEK_API_KEY from the environment unless the
crate injects overrides, 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 resolves and launches it. There
is no separate JSON-RPC agent program: the stdio JSON-RPC server the SDK
talks to is a plugin row inside the runtime's profile bundle. The runtime is
the ordinary dsh CLI from
deepseek-harness, booted
under the sdk profile (or any profile you name via Config::profile).
Three routes to a runtime:
Route A — the npm-published CLI (recommended)
npm install -g @deepseek-ai/dsh
export DSH_RUNTIME_BIN="$(command -v dsh)"
The dsh CLI is published on npm as @deepseek-ai/dsh. A bare
npm install -g @deepseek-ai/dsh installs the latest dist-tag, which can
lag behind upstream's newest release; npm install -g @deepseek-ai/dsh@alpha
tracks the newest. This crate's CI verifies the npm route with a keyless
handshake on every pull request, and the exact version under test lives in
.github/workflows/ci.yml.
The installed bin is a Node.js script, so Node.js must be on PATH for
the SDK to launch it. The crate spawns the resolved program directly without
a shell, and the npm bin is a Node.js script rather than a native executable
— on Windows, prefer the self-contained wheel route (Route B).
Route B — the platform wheel (self-contained, no Node.js)
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. The SDK launches the executable
directly (always injecting the resolved DSH_HOME, so the home is explicit
even on first boot).
The wheel ships the runtime as a self-contained single-file executable — no
system Node.js needed at runtime (the plugin tree is embedded) — and installs
the normal dsh CLI as deepseek-harness-sdk-runtime-<platform>-<arch>.
Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and
Windows x64 (Windows uses the .exe suffix); no Windows arm64 wheel is
published. macOS needs its sibling -spawn-helper beside the executable
(node-pty), and the Linux/macOS wheels carry a -rg ripgrep sidecar
(Windows -rg.exe) — copy any sidecar along when you relocate the executable.
Because the wheel needs no system Node.js at runtime, it stays the fallback
for Windows and for users who cannot install Node.js.
Route C — build from source
Build the runtime executable from source with the
build-exe-for-python-sdk script from the
official repository, then
point DSH_RUNTIME_BIN (or Config::dsh_bin) at the built executable.
Building from source is the only route that reproduces the exact contract
basis this crate was verified against (git checkout c389f96bf3 in the
official repository before building), and it is the route for any platform
that has no published wheel.
How the SDK resolves the runtime
Config::dsh_bin(non-empty);DSH_RUNTIME_BINfrom the parent environment (non-empty);- otherwise
Error::RuntimeNotFound, whose message names the acquisition routes and cites the official repository.
An empty Config::dsh_bin and an empty DSH_RUNTIME_BIN both count as
absent, so resolution never produces an unlaunchable empty program.
DSH_RUNTIME_BIN is explicitly preserved after the runtime_bin →
dsh_bin rename: it is a supported product surface, not a compatibility
shim for a removed field.
The launch argv is exactly --profile <profile> (default "sdk") followed
by one --patch <absolute path> pair per configured Config::patches entry,
in caller order. Patch paths are resolved absolute before spawn. The crate
never passes application arguments and never emits the diagnostic
--dump-config / --dump-default-config subcommands. An empty profile
is rejected locally before spawn.
DSH_HOME resolution
The harness home resolves with the runtime's own precedence, highest first:
- an explicit
Config::dsh_home; - a non-empty
$DSH_HOME(first fromConfig::env, then inherited from the parent environment) — blank or whitespace-only counts as unset; ~/.dsh.
The resolved home is normalized to an absolute path with ~ expanded, is
created when absent so a fresh home boots, and is always injected into the
runtime child environment as DSH_HOME.
The selection is observable, never silent: call
Config::resolve_dsh_home(&parent_env) to read what a launch would pick, and
DeepSeekHarness::dsh_home() on a running instance to read the exact home it
was launched with.
Deliberate divergence from the Python SDK (documented; do not "fix"): where Python raises
ValueErrorrather than falling back, this crate resolves~/.dsh—~/.dshis the runtime's own documented default. The crate follows the runtime's precedence (like the TypeScript SDK) instead of Python's refusal. The real defect the Python refusal guards against is a silent home selection; observability above addresses that instead.
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. ExposesLaunchSpec,ClientTimeouts, andNotificationSubscription.DeepSeekHarness/Session(high-level): the Python-parity owned-run API on top ofHarnessClient.Inputaccepts either plain text (Input::Text) or raw content blocks (Input::Blocks), mirroring Python'snormalize_input.
DeepSeekHarness::start
start is eager: it resolves the runtime, resolves and creates the
harness home (see DSH_HOME resolution), composes
the child environment, 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.
The initialize handshake is bounded by Config::initialize_timeout
(default 30 s, following Python; None means unbounded and is a
deliberate opt-out). The bound applies to the handshake only — never to the
activity interval or to session/prompt. On expiry start returns
Error::RequestTimeout { method: "initialize", .. }, whose message names
the selected profile (e.g. (selected dsh profile 'sdk')), and the child
is closed rather than left running.
Config::reasoning_effort (an Option<String>) is sent on initialize as
the wire key reasoningEffort only when set to a non-empty string: unset
or blank values are omitted entirely. The runtime rejects a non-string or
empty reasoningEffort, so blank is dropped rather than sent.
Config::cwd is resolved absolute and sent as initialize.cwd;
Config::runtime_cwd sets the subprocess cwd and defaults to cwd.
Config::request_timeout bounds every other 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:
- Subscribe to the session tree before writing the prompt, so no notification for this turn can be missed.
- Send
session/prompt(bounded byConfig::request_timeout). - Wait for the durable
agent/inbox/splicedreceipt whoseinserted[].idequals the returned message id (the field isid, notmessageId); notifications before the receipt are dropped from botheventsandnotifications. - 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.started / subagent.finished) in transport
order.
run accepts an optional per-notification callback:
pub async fn run(
&self,
input: Input,
on_notification: Option<&(dyn Fn(&Notification) + Send + Sync)>,
) -> Result<RunResult, Error>
The callback observes every notification delivered to the run's session-tree
subscription, in wire order, and is invoked as each notification
arrives, not deferred until the end. It is a pure observer: it cannot
change the returned RunResult (with and without a callback, events and
notifications hold the same sets in the same order), it is a layer over the
existing notification path rather than a second subscription, and it is
optional and additive (passing None behaves exactly like the plain run
path). A panic inside the callback is a caller bug and propagates; the crate
does not swallow it.
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.
Session format v2
The runtime's session.event vocabulary is session format v2 (no wire
change). The crate documents the v2 vocabulary and keeps event payloads
untyped, so the run path is unaffected:
assistant/messagenow carries an embeddedstream: AssistantStreamRecord[];assistant/attemptwas added;assistant/chunkwas removed — the crate does not claimassistant/chunksupport and does not parse the embedded v2 stream (non-goal).
Content block vocabulary
ContentBlock models the DSH ContentBlockMap with six typed variants
plus an Unknown fallback for unrecognized tags and malformed bodies:
| Variant | Shape |
|---|---|
| text | {type:"text", text} |
| reasoning | {type:"reasoning", text} |
| image | {type:"image", attachment: ImageAttachmentRef} |
| file | {type:"file", attachment: FileAttachmentRef} |
| tool-call | {type:"tool-call", id, name, arguments} — arguments is a raw JSON string |
| tool-result | {type:"tool-result", toolCallId, content: ContentBlock[], isError?} — content is recursive |
FileAttachmentRef is {attachmentId, name, bytes}. ImageAttachmentRef is
{attachmentId, mediaType, bytes, width, height, name?, originalDimensions?};
originalDimensions is present only when normalization reduced the image, and
parses → serializes without loss. An unrecognized type (or a known tag with
a malformed body) falls through to ContentBlock::Unknown, preserving the raw
object verbatim.
RunResult
RunResult follows the Python SDK field set — exactly five fields, no
session_root (upstream removed it and asserts its absence). The TypeScript
SDK's RunResult lacks finish_reason; Rust follows Python:
| Field | Python | TypeScript | Rust (this crate) |
|---|---|---|---|
| session_id / sessionId | yes | yes | session_id: String |
| final_response / finalResponse | yes | yes | final_response: String |
| finish_reason | yes | no | finish_reason: Option<String> |
| events | yes (root session only) | yes | events: Vec<serde_json::Value> |
| notifications | yes (root + descendants, transport order) | yes | notifications: Vec<Notification> |
| session_root | no (removed) | no | does not exist |
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:
| Variant | Meaning |
|---|---|
| Error::RuntimeNotFound | No runtime binary configured anywhere; message names the acquisition routes |
| Error::Config | Invalid launch configuration (e.g. an empty profile), rejected locally before spawn |
| Error::TransportClosed | Runtime process not running, or stdio closed unexpectedly; carries diagnostics (exit status and captured stderr tail) |
| Error::RequestTimeout | A request got no response within the configured timeout; carries the method name and, for initialize, names the selected profile (e.g. (selected dsh profile 'sdk')) |
| Error::SdkProtocol | A protocol-level violation (missing server identity, missing messageId, finish_reason extraction failure, malformed notifications, subscription lag); Error::is_protocol() detects it |
| Error::JsonRpc | A JSON-RPC error response, preserving code (Option<i64>) and optional data |
| Error::Io / Error::Json | I/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
The wire has four server-to-client notifications: session.event,
session.status, subagent.started, and subagent.finished. 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. For arrival-order observation of every
notification, pass the per-notification callback to Session::run (above).
Environment variables
The parent environment is inherited wholesale; the SDK injects or overrides
only the keys below. Crate-injected values are applied first and the caller's
Config::env entries are applied after, so on collision the caller's value
wins (Python dict.update semantics) — except DSH_HOME, which is resolved,
never post-resolution overridden:
| Variable | Role | Semantics |
|---|---|---|
| DSH_HOME | Harness home (child env) | Always written with the resolved absolute, ~-expanded home. The caller's value is an input to resolution (Config::dsh_home → non-empty DSH_HOME → ~/.dsh), not a post-resolution override; read the selection back via Config::resolve_dsh_home / DeepSeekHarness::dsh_home |
| DSH_RUNTIME_BIN | Runtime binary resolution | Consulted when Config::dsh_bin is not set; empty counts as absent. Explicitly preserved after the runtime_bin → dsh_bin rename (not a compatibility shim) |
| DEEPSEEK_BASE_URL / DEEPSEEK_API_KEY | Model endpoint and credentials | Inherited as-is; overridden when Config::base_url / Config::api_key is configured, and a caller-supplied Config::env entry for either key is applied after injection and wins on collision |
| DSH_CORDIS_CONFIG / DSH_SESSION_ROOT / DSH_CWD | Removed — never written | No reader upstream; see the removal table |
Deliberate divergences
Each divergence below is deliberate and documented; a contributor must not "fix" it back to reference behaviour without a superseding spec decision.
DSH_HOMEfallback — the crate resolves~/.dshwhere Python raisesValueError(DSH_HOMEresolution).- No client-directed request API — Python exposes
next_request/respond/notify; the crate exposes none (the runtime emits no client-directed requests; they are auto-answered-32601). Non-goal, not a gap. - Stricter malformed-notification policy — a
session.event/session.statuswhose payload fails its shape check fails the run withError::SdkProtocol. Python silently skips a malformed event/status and only raises on a malformed lastturn/end; TypeScript raises for a malformedsession.eventbut ignores a malformedsession.status. This converts a silent hang into a typed failure and is not Python parity. Related local robustness choices: the embedded stderr tail is capped (8 KiB, newest lines first) and the broadcast buffer is bounded with a fail-fast on observed lag. - Strict
serverInfo.nameequality —initializerequires the identity to be exactlydeepseek-harness-sdk-runtime; Python treats the fields as optional and TypeScript checks presence only. An upstream rename fails loudly instead of being silently accepted. - No
run()convenience, no lazy start — the crate requires an explicitDeepSeekHarness::start; Python and TypeScript can start lazily on first use. Non-goal, not a gap.
Removed surface (v0.1 → current)
The following v0.1 identifiers are gone — no alias, no deprecated shim. Each row names what it was and what replaces it:
| Removed item | What it was | Replacement |
|---|---|---|
| Config::session_root | claimed to control where sessions land | Config::dsh_home — sessions live under $DSH_HOME/sessions |
| Config::cordis_config | path to a cordis.yml config file | the profile tree (Config::profile + Config::patches) — no config file is passed to the runtime |
| the DSH_CORDIS_CONFIG injection | wrote DSH_CORDIS_CONFIG into the child env | the profile tree — no reader upstream |
| the DSH_SESSION_ROOT injection | wrote DSH_SESSION_ROOT into the child env | Config::dsh_home — sessions live under $DSH_HOME/sessions |
| the DSH_CWD injection | wrote DSH_CWD into the child env | Config::cwd — sent as initialize.cwd |
| Config::launch_args_override | replaced the whole argv with an opaque list | Config::dsh_bin + Config::profile + Config::patches — the launch is composed from typed fields |
| Config::runtime_bin | the runtime-path override field | Config::dsh_bin (rename; DSH_RUNTIME_BIN env route preserved) |
| RunResult::session_root | surfaced the session directory on every result | dropped — no replacement (upstream removed it) |
| assets/cordis.yml | bundled default config file | the profile bundle — it mounted a package deleted upstream |
| DEFAULT_CORDIS_YML | embedded the config file above | the profile bundle |
| bundled_default_config_path | the temp extraction path for the config | the profile bundle — its whole purpose was the deleted injection channel |
| the assets/cordis.yml entry in Cargo.toml [package] include | shipped the deleted file | — (the file no longer exists) |
Testing
cargo test— wire-protocol, lifecycle, andSession::runsemantics suites against a scripted fake runtime (no real runtime needed).tests/real_runtime.rs— a keyless handshake tier (start →initialize→ close against a realdsh, no API key) that runs when adshbinary is available, plus a live-turn tier gated onDEEPSEEK_API_KEY; otherwise it prints an explicit skip notice and passes, socargo testis 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. Upstream publishes the runtime for 5 targets — the list is in Runtime acquisition, Route B.
MSRV: current stable Rust (no minimum is pinned in Cargo.toml; the crate
tracks the stable toolchain).
Known limitations
- Pre-release software — the crate ships pre-release versions while the
runtime protocol settles; the API may change before
0.1.0. The real-runtime tests are 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::runwaits until the root session reportsidle; closing the harness mid-turn abandons the in-flight turn. AConfig::request_timeoutonly abandons the local wait — the server-side work still runs until close. - No version negotiation — the runtime identifies as
serverInfo0.0.1 pre-release, andinitializeenforces a strictserverInfo.namecheck (deepseek-harness-sdk-runtime): the protocol declares the name wire-stable and has no negotiation, so an unexpected identity is a hardError::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.
License
Apache-2.0.
Comments
Loading…
Similar plugins
by PlusQi
个人 DeepSeek Harness (DSH) 插件集
★ 0
MIT
JavaScript
Sep 10, 2026
dsh plugin --profile web add dsh-pluginsby sugarforever
DeepSeek Harness Plugin for Lark Integration
★ 28
MIT
TypeScript
Sep 7, 2026
dsh plugin --profile web add @sugarforever/dsh-larkby jwilson411
A minimal, tested template for DeepSeek Harness plugins.
★ 0
MIT
JavaScript
Sep 1, 2026
dsh plugin --profile web add dsh-plugin-kitScouts the deepseek-harness repo and every dsh-plugin-tagged repository to discover harnesses related to your goal, then judges each as worth trying, watching, or skipping.
★ 0
dsh plugin --profile web add dsh-plugin-scoutby scwlkq
Single-package durable task board plugin for DeepSeek Harness
★ 3
MIT
JavaScript
Aug 18, 2026
dsh plugin --profile web add dsh-task-boardby bobby-sheng
Search, inspect, update, and install DeepSeek Harness plugins from DSH Get.
★ 0
MIT
JavaScript
Aug 19, 2026
dsh plugin --profile web add dshget-plugin